From 2b9b46bd158a5eb00839330053c80288fbeac602 Mon Sep 17 00:00:00 2001 From: Md Bayazid Bostame Date: Sat, 28 Mar 2026 20:45:07 +0100 Subject: [PATCH] feat: add dev and prod deployment scaffolding --- .env.dev.example | 46 ++++++++++++++++++++ .env.prod.example | 48 +++++++++++++++++++++ .env.test.example | 48 +++++++++++++++++++++ .github/workflows/deploy-prod.yml | 33 ++++++++++++++ .github/workflows/deploy-test.yml | 36 ++++++++++++++++ .gitignore | 3 ++ DEPLOYMENT.md | 68 +++++++++++++++++++++++++++++ backend/Dockerfile | 2 +- backend/entrypoint-web-prod.sh | 9 ++++ backend/entrypoint-worker-prod.sh | 4 ++ deploy/Caddyfile | 15 +++++++ docker-compose.prod.yml | 72 +++++++++++++++++++++++++++++++ scripts/deploy_stack.sh | 37 ++++++++++++++++ 13 files changed, 420 insertions(+), 1 deletion(-) create mode 100644 .env.dev.example create mode 100644 .env.prod.example create mode 100644 .env.test.example create mode 100644 .github/workflows/deploy-prod.yml create mode 100644 .github/workflows/deploy-test.yml create mode 100644 DEPLOYMENT.md create mode 100755 backend/entrypoint-web-prod.sh create mode 100755 backend/entrypoint-worker-prod.sh create mode 100644 deploy/Caddyfile create mode 100644 docker-compose.prod.yml create mode 100755 scripts/deploy_stack.sh diff --git a/.env.dev.example b/.env.dev.example new file mode 100644 index 0000000..ab3ea14 --- /dev/null +++ b/.env.dev.example @@ -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 diff --git a/.env.prod.example b/.env.prod.example new file mode 100644 index 0000000..284485c --- /dev/null +++ b/.env.prod.example @@ -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 diff --git a/.env.test.example b/.env.test.example new file mode 100644 index 0000000..aec0f5e --- /dev/null +++ b/.env.test.example @@ -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 diff --git a/.github/workflows/deploy-prod.yml b/.github/workflows/deploy-prod.yml new file mode 100644 index 0000000..557ca5f --- /dev/null +++ b/.github/workflows/deploy-prod.yml @@ -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 diff --git a/.github/workflows/deploy-test.yml b/.github/workflows/deploy-test.yml new file mode 100644 index 0000000..a9dcd01 --- /dev/null +++ b/.github/workflows/deploy-test.yml @@ -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 diff --git a/.gitignore b/.gitignore index ce0ff02..be75676 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,9 @@ __pycache__/ .env .env.* !.env.example +!.env.dev.example +!.env.test.example +!.env.prod.example .venv/ venv/ db.sqlite3 diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md new file mode 100644 index 0000000..807ec8c --- /dev/null +++ b/DEPLOYMENT.md @@ -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`. diff --git a/backend/Dockerfile b/backend/Dockerfile index c9f8682..1325bd7 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -15,5 +15,5 @@ COPY requirements.txt /tmp/requirements.txt RUN pip install --no-cache-dir -r /tmp/requirements.txt 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 diff --git a/backend/entrypoint-web-prod.sh b/backend/entrypoint-web-prod.sh new file mode 100755 index 0000000..91c3e74 --- /dev/null +++ b/backend/entrypoint-web-prod.sh @@ -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 - diff --git a/backend/entrypoint-worker-prod.sh b/backend/entrypoint-worker-prod.sh new file mode 100755 index 0000000..07bc3ad --- /dev/null +++ b/backend/entrypoint-worker-prod.sh @@ -0,0 +1,4 @@ +#!/bin/sh +set -e + +exec celery -A config worker -l info diff --git a/deploy/Caddyfile b/deploy/Caddyfile new file mode 100644 index 0000000..5eb8061 --- /dev/null +++ b/deploy/Caddyfile @@ -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 +} diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..2a8f915 --- /dev/null +++ b/docker-compose.prod.yml @@ -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: diff --git a/scripts/deploy_stack.sh b/scripts/deploy_stack.sh new file mode 100755 index 0000000..7680bf7 --- /dev/null +++ b/scripts/deploy_stack.sh @@ -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