feat: add dev and prod deployment scaffolding

This commit is contained in:
Md Bayazid Bostame
2026-03-28 20:45:07 +01:00
parent 3c0073142f
commit 2b9b46bd15
13 changed files with 420 additions and 1 deletions

46
.env.dev.example Normal file
View File

@@ -0,0 +1,46 @@
DJANGO_SECRET_KEY=change-me
DJANGO_DEBUG=1
DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1
DJANGO_CSRF_TRUSTED_ORIGINS=
DJANGO_SECURE_COOKIES=0
DJANGO_SECURE_SSL_REDIRECT=0
SESSION_IDLE_TIMEOUT_SECONDS=1800
SENSITIVE_ACTION_REAUTH_SECONDS=1200
POSTGRES_DB=workdock
POSTGRES_USER=workdock
POSTGRES_PASSWORD=workdock
POSTGRES_HOST=db
POSTGRES_PORT=5432
REDIS_URL=redis://redis:6379/0
CELERY_TASK_ALWAYS_EAGER=0
RATE_LIMIT_ENABLED=1
RATE_LIMIT_LOGIN_LIMIT=8
RATE_LIMIT_LOGIN_WINDOW=300
RATE_LIMIT_PASSWORD_RESET_LIMIT=5
RATE_LIMIT_PASSWORD_RESET_WINDOW=600
RATE_LIMIT_ADMIN_ACTION_LIMIT=20
RATE_LIMIT_ADMIN_ACTION_WINDOW=300
EMAIL_HOST=mailhog
EMAIL_PORT=1025
EMAIL_HOST_USER=
EMAIL_HOST_PASSWORD=
EMAIL_USE_TLS=0
EMAIL_USE_SSL=0
DEFAULT_FROM_EMAIL=onboarding@example.local
TEST_NOTIFICATION_EMAIL=hr@example.local
IT_ONBOARDING_NOTIFICATION_EMAIL=it@workdock.de
GENERAL_INFO_NOTIFICATION_EMAIL=info@workdock.de
BUSINESS_CARD_NOTIFICATION_EMAIL=cards@workdock.de
HR_WORKS_NOTIFICATION_EMAIL=hr@workdock.de
KEY_NOTIFICATION_EMAIL=keys@workdock.de
NEXTCLOUD_BASE_URL=https://nextcloud.example.com/remote.php/dav/files/onboarding
NEXTCLOUD_USERNAME=onboarding@example.com
NEXTCLOUD_PASSWORD=change-me
NEXTCLOUD_DIRECTORY=Group-on-off-boarding
NEXTCLOUD_ENABLED=0
PDF_OUTPUT_DIR=/app/media/pdfs

48
.env.prod.example Normal file
View File

@@ -0,0 +1,48 @@
DJANGO_SECRET_KEY=change-me-long-random-value
DJANGO_DEBUG=0
DJANGO_ALLOWED_HOSTS=workdock.example.com
DJANGO_CSRF_TRUSTED_ORIGINS=https://workdock.example.com
DJANGO_SECURE_COOKIES=1
DJANGO_SECURE_SSL_REDIRECT=1
SESSION_IDLE_TIMEOUT_SECONDS=1800
SENSITIVE_ACTION_REAUTH_SECONDS=1200
POSTGRES_DB=workdock
POSTGRES_USER=workdock
POSTGRES_PASSWORD=change-me-db-password
POSTGRES_HOST=db
POSTGRES_PORT=5432
REDIS_URL=redis://redis:6379/0
CELERY_TASK_ALWAYS_EAGER=0
RATE_LIMIT_ENABLED=1
RATE_LIMIT_LOGIN_LIMIT=8
RATE_LIMIT_LOGIN_WINDOW=300
RATE_LIMIT_PASSWORD_RESET_LIMIT=5
RATE_LIMIT_PASSWORD_RESET_WINDOW=600
RATE_LIMIT_ADMIN_ACTION_LIMIT=20
RATE_LIMIT_ADMIN_ACTION_WINDOW=300
EMAIL_HOST=smtp.example.com
EMAIL_PORT=587
EMAIL_HOST_USER=mailer@example.com
EMAIL_HOST_PASSWORD=change-me
EMAIL_USE_TLS=1
EMAIL_USE_SSL=0
DEFAULT_FROM_EMAIL=onboarding@example.com
TEST_NOTIFICATION_EMAIL=hr@example.com
IT_ONBOARDING_NOTIFICATION_EMAIL=it@example.com
GENERAL_INFO_NOTIFICATION_EMAIL=info@example.com
BUSINESS_CARD_NOTIFICATION_EMAIL=cards@example.com
HR_WORKS_NOTIFICATION_EMAIL=hr@example.com
KEY_NOTIFICATION_EMAIL=keys@example.com
NEXTCLOUD_BASE_URL=https://nextcloud.example.com/remote.php/dav/files/onboarding
NEXTCLOUD_USERNAME=onboarding@example.com
NEXTCLOUD_PASSWORD=change-me
NEXTCLOUD_DIRECTORY=Group-on-off-boarding
NEXTCLOUD_ENABLED=1
PDF_OUTPUT_DIR=/app/media/pdfs
APP_PORT=8088
SITE_ADDRESS=workdock.example.com

48
.env.test.example Normal file
View File

@@ -0,0 +1,48 @@
DJANGO_SECRET_KEY=change-me-long-random-value
DJANGO_DEBUG=0
DJANGO_ALLOWED_HOSTS=192.168.2.55,localhost,127.0.0.1
DJANGO_CSRF_TRUSTED_ORIGINS=http://192.168.2.55:8088
DJANGO_SECURE_COOKIES=0
DJANGO_SECURE_SSL_REDIRECT=0
SESSION_IDLE_TIMEOUT_SECONDS=1800
SENSITIVE_ACTION_REAUTH_SECONDS=1200
POSTGRES_DB=workdock
POSTGRES_USER=workdock
POSTGRES_PASSWORD=change-me-db-password
POSTGRES_HOST=db
POSTGRES_PORT=5432
REDIS_URL=redis://redis:6379/0
CELERY_TASK_ALWAYS_EAGER=0
RATE_LIMIT_ENABLED=1
RATE_LIMIT_LOGIN_LIMIT=8
RATE_LIMIT_LOGIN_WINDOW=300
RATE_LIMIT_PASSWORD_RESET_LIMIT=5
RATE_LIMIT_PASSWORD_RESET_WINDOW=600
RATE_LIMIT_ADMIN_ACTION_LIMIT=20
RATE_LIMIT_ADMIN_ACTION_WINDOW=300
EMAIL_HOST=mailhog
EMAIL_PORT=1025
EMAIL_HOST_USER=
EMAIL_HOST_PASSWORD=
EMAIL_USE_TLS=0
EMAIL_USE_SSL=0
DEFAULT_FROM_EMAIL=onboarding@test.local
TEST_NOTIFICATION_EMAIL=hr@test.local
IT_ONBOARDING_NOTIFICATION_EMAIL=it@test.local
GENERAL_INFO_NOTIFICATION_EMAIL=info@test.local
BUSINESS_CARD_NOTIFICATION_EMAIL=cards@test.local
HR_WORKS_NOTIFICATION_EMAIL=hr@test.local
KEY_NOTIFICATION_EMAIL=keys@test.local
NEXTCLOUD_BASE_URL=
NEXTCLOUD_USERNAME=
NEXTCLOUD_PASSWORD=
NEXTCLOUD_DIRECTORY=
NEXTCLOUD_ENABLED=0
PDF_OUTPUT_DIR=/app/media/pdfs
APP_PORT=8088
SITE_ADDRESS=:80

33
.github/workflows/deploy-prod.yml vendored Normal file
View File

@@ -0,0 +1,33 @@
name: Deploy Production
on:
workflow_dispatch:
concurrency:
group: deploy-prod
cancel-in-progress: false
jobs:
deploy:
runs-on: ubuntu-latest
environment: production
steps:
- name: Deploy over SSH
uses: appleboy/ssh-action@v1.2.0
with:
host: ${{ secrets.PROD_DEPLOY_HOST }}
username: ${{ secrets.PROD_DEPLOY_USER }}
key: ${{ secrets.PROD_DEPLOY_SSH_KEY }}
port: ${{ secrets.PROD_DEPLOY_PORT || 22 }}
script: |
set -e
REPO_URL="git@github.com:${{ github.repository }}.git"
DEPLOY_DIR="${{ secrets.PROD_DEPLOY_PATH }}"
if [ ! -d "$DEPLOY_DIR/.git" ]; then
git clone "$REPO_URL" "$DEPLOY_DIR"
fi
cd "$DEPLOY_DIR"
git fetch --all --prune
git checkout main || git checkout -b main origin/main
git reset --hard origin/main
RUN_DJANGO_CHECK=1 ./scripts/deploy_stack.sh .env.prod docker-compose.prod.yml

36
.github/workflows/deploy-test.yml vendored Normal file
View File

@@ -0,0 +1,36 @@
name: Deploy Test
on:
workflow_dispatch:
push:
branches:
- develop
concurrency:
group: deploy-test-${{ github.ref }}
cancel-in-progress: true
jobs:
deploy:
runs-on: ubuntu-latest
environment: development
steps:
- name: Deploy over SSH
uses: appleboy/ssh-action@v1.2.0
with:
host: ${{ secrets.TEST_DEPLOY_HOST }}
username: ${{ secrets.TEST_DEPLOY_USER }}
key: ${{ secrets.TEST_DEPLOY_SSH_KEY }}
port: ${{ secrets.TEST_DEPLOY_PORT || 22 }}
script: |
set -e
REPO_URL="git@github.com:${{ github.repository }}.git"
DEPLOY_DIR="${{ secrets.TEST_DEPLOY_PATH }}"
if [ ! -d "$DEPLOY_DIR/.git" ]; then
git clone "$REPO_URL" "$DEPLOY_DIR"
fi
cd "$DEPLOY_DIR"
git fetch --all --prune
git checkout develop || git checkout -b develop origin/develop
git reset --hard ${{ github.sha }}
RUN_DJANGO_CHECK=0 DEPLOY_HEALTH_URL="http://127.0.0.1:8088/healthz/" ./scripts/deploy_stack.sh .env.test docker-compose.prod.yml

3
.gitignore vendored
View File

@@ -6,6 +6,9 @@ __pycache__/
.env .env
.env.* .env.*
!.env.example !.env.example
!.env.dev.example
!.env.test.example
!.env.prod.example
.venv/ .venv/
venv/ venv/
db.sqlite3 db.sqlite3

68
DEPLOYMENT.md Normal file
View File

@@ -0,0 +1,68 @@
# Deployment
## Branch strategy
- `develop`: test/staging deployments
- `main`: production deployments
- keep one private GitHub repository
## Local development
- current `docker-compose.yml` remains the development stack
- use `.env` or `.env.dev.example` as the template
## Test deployment server
- copy `.env.test.example` to `.env.test` on the server
- recommended path: `/opt/workdock/.env.test`
- current test target: `192.168.2.55`
## Production deployment server
- copy `.env.prod.example` to `.env.prod` on the server
- use HTTPS and secure cookies in production
## Server bootstrap
Install:
- `git`
- `docker`
- `docker compose plugin`
- `curl`
Recommended app directory:
- `/opt/workdock`
## First clone on server
```bash
git clone git@github.com:OWNER/REPO.git /opt/workdock
cd /opt/workdock
cp .env.test.example .env.test
```
## Test deploy manually
```bash
cd /opt/workdock
./scripts/deploy_stack.sh .env.test docker-compose.prod.yml
```
## Production deploy manually
```bash
cd /opt/workdock
./scripts/deploy_stack.sh .env.prod docker-compose.prod.yml
```
## GitHub Actions secrets
### Development environment
- `TEST_DEPLOY_HOST`
- `TEST_DEPLOY_USER`
- `TEST_DEPLOY_SSH_KEY`
- `TEST_DEPLOY_PORT`
- `TEST_DEPLOY_PATH`
### Production environment
- `PROD_DEPLOY_HOST`
- `PROD_DEPLOY_USER`
- `PROD_DEPLOY_SSH_KEY`
- `PROD_DEPLOY_PORT`
- `PROD_DEPLOY_PATH`
## Important note for the test server
`.env.test.example` is intentionally configured for an HTTP LAN test deployment.
That means `RUN_DJANGO_CHECK=0` is used in the test deploy workflow, because the application security checks require secure cookies when `DEBUG=0`.
For real production, use `.env.prod` behind HTTPS and keep `RUN_DJANGO_CHECK=1`.

View File

@@ -15,5 +15,5 @@ COPY requirements.txt /tmp/requirements.txt
RUN pip install --no-cache-dir -r /tmp/requirements.txt RUN pip install --no-cache-dir -r /tmp/requirements.txt
COPY . /app COPY . /app
RUN chmod +x /app/entrypoint-web.sh /app/entrypoint-worker.sh RUN chmod +x /app/entrypoint-web.sh /app/entrypoint-worker.sh /app/entrypoint-web-prod.sh /app/entrypoint-worker-prod.sh
RUN chown -R app:app /app RUN chown -R app:app /app

9
backend/entrypoint-web-prod.sh Executable file
View File

@@ -0,0 +1,9 @@
#!/bin/sh
set -e
exec gunicorn config.wsgi:application \
--bind 0.0.0.0:8000 \
--workers 3 \
--timeout 120 \
--access-logfile - \
--error-logfile -

View File

@@ -0,0 +1,4 @@
#!/bin/sh
set -e
exec celery -A config worker -l info

15
deploy/Caddyfile Normal file
View File

@@ -0,0 +1,15 @@
{$SITE_ADDRESS} {
encode gzip zstd
handle_path /static/* {
root * /srv/static
file_server
}
handle_path /media/* {
root * /srv/media
file_server
}
reverse_proxy web:8000
}

72
docker-compose.prod.yml Normal file
View File

@@ -0,0 +1,72 @@
services:
caddy:
image: caddy:2.10-alpine
restart: unless-stopped
env_file:
- ${APP_ENV_FILE:-.env.prod}
ports:
- "${APP_PORT:-8088}:80"
volumes:
- ./deploy/Caddyfile:/etc/caddy/Caddyfile:ro
- media_data:/srv/media:ro
- static_data:/srv/static:ro
depends_on:
- web
web:
build:
context: ./backend
command: sh -c "./entrypoint-web-prod.sh"
restart: unless-stopped
env_file:
- ${APP_ENV_FILE:-.env.prod}
volumes:
- media_data:/app/media
- static_data:/app/staticfiles
- backup_data:/app/backups
user: "app"
depends_on:
- db
- redis
healthcheck:
test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/healthz/', timeout=5)\""]
interval: 20s
timeout: 8s
retries: 5
start_period: 20s
worker:
build:
context: ./backend
command: sh -c "./entrypoint-worker-prod.sh"
restart: unless-stopped
env_file:
- ${APP_ENV_FILE:-.env.prod}
volumes:
- media_data:/app/media
- static_data:/app/staticfiles
- backup_data:/app/backups
user: "app"
depends_on:
- db
- redis
db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB:-workdock}
POSTGRES_USER: ${POSTGRES_USER:-workdock}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-workdock}
volumes:
- postgres_data:/var/lib/postgresql/data
redis:
image: redis:7-alpine
restart: unless-stopped
volumes:
postgres_data:
media_data:
static_data:
backup_data:

37
scripts/deploy_stack.sh Executable file
View File

@@ -0,0 +1,37 @@
#!/usr/bin/env bash
set -euo pipefail
ENV_FILE="${1:-.env.prod}"
COMPOSE_FILE="${2:-docker-compose.prod.yml}"
HEALTH_URL="${DEPLOY_HEALTH_URL:-http://127.0.0.1:${APP_PORT:-8088}/healthz/}"
RUN_DJANGO_CHECK="${RUN_DJANGO_CHECK:-1}"
export APP_ENV_FILE="$ENV_FILE"
COMPOSE=(docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE")
if [[ ! -f "$ENV_FILE" ]]; then
echo "Missing env file: $ENV_FILE" >&2
exit 1
fi
"${COMPOSE[@]}" build web worker caddy
"${COMPOSE[@]}" up -d db redis
"${COMPOSE[@]}" run --rm web python manage.py migrate --noinput
"${COMPOSE[@]}" run --rm web python manage.py bootstrap_initial_users
"${COMPOSE[@]}" run --rm web python manage.py collectstatic --noinput
if [[ "$RUN_DJANGO_CHECK" == "1" ]]; then
"${COMPOSE[@]}" run --rm web python manage.py check
fi
"${COMPOSE[@]}" up -d web worker caddy
for i in $(seq 1 30); do
if curl --fail --silent --show-error --max-time 5 "$HEALTH_URL" >/dev/null; then
echo "Deployment healthy: $HEALTH_URL"
exit 0
fi
sleep 2
done
echo "Health check did not become ready in time: $HEALTH_URL" >&2
exit 1