Files
python-tdd/infra/CD-PLAN.md
2026-02-13 15:27:59 -05:00

7.6 KiB

Continuous Deployment: Auto-deploy to staging when CI passes

Context

CI is complete (Percival ch. 25). The Woodpecker pipeline runs 57 UTs + 9 FTs on every push to Gitea, but deployment to staging is still manual (ansible-playbook infra/deploy-playbook.yaml). This plan adds CD: when tests pass on the main branch, the pipeline automatically builds the Docker image, pushes it to the Gitea container registry, and deploys it to staging.earthmanrpg.me.

Architecture: Build in CI pipeline -> push to Gitea registry -> SSH to staging -> pull + restart


Stage 1: Enable Docker socket in Woodpecker pipeline steps

The Woodpecker agent already has the Docker socket mounted (line 53 of infra/cicd/docker-compose.yaml), but pipeline steps (containers the agent launches) don't inherit it. We need to tell the agent to pass it through.

1a. Edit infra/cicd/docker-compose.yaml -- add env var to woodpecker-agent

Add this to the agent's environment: block:

- WOODPECKER_BACKEND_DOCKER_VOLUMES=/var/run/docker.sock:/var/run/docker.sock

Why: This makes the host Docker socket available inside every pipeline step container. Without it, the docker build and docker push commands in our pipeline step would fail with "cannot connect to Docker daemon."

1b. Re-deploy CI stack

From WSL:

ansible-playbook infra/cicd-playbook.yaml -i infra/inventory.ini --ask-vault-pass

This uploads the updated docker-compose and restarts the CI services.


Stage 2: Prepare staging server for CD

Currently deploy-playbook.yaml does everything in one shot: installs Docker, builds the image locally, uploads it, runs it with inline env: vars, copies static files, migrates. For CD, we need to split "one-time setup" from "per-deploy actions."

2a. Create infra/gamearray.env.j2 -- container env file template (new file)

DJANGO_DEBUG_FALSE=1
DJANGO_SECRET_KEY={{ secret_key.content | b64decode }}
DJANGO_ALLOWED_HOST={{ django_allowed_host }}
DJANGO_DB_PATH=/home/nonroot/db.sqlite3
EMAIL_HOST_USER={{ email_host_user }}
EMAIL_HOST_PASSWORD={{ email_host_password }}
MAILGUN_API_KEY={{ mailgun_api_key }}

Why: Instead of passing env vars inline in docker run, we template a .env file onto the staging server. The deploy script references it with --env-file. This keeps secrets out of the pipeline YAML and on the server, managed by Ansible vault.

2b. Create infra/deploy.sh.j2 -- deploy script template (new file)

#!/bin/bash
set -euo pipefail

IMAGE=gitea.earthmanrpg.me/discoman/gamearray:latest

echo "==> Pulling latest image..."
docker pull "$IMAGE"

echo "==> Stopping old container..."
docker stop gamearray 2>/dev/null || true
docker rm gamearray 2>/dev/null || true

echo "==> Starting new container..."
docker run -d --name gamearray \
  --env-file /opt/gamearray/gamearray.env \
  -p 127.0.0.1:8888:8888 \
  "$IMAGE"

echo "==> Running migrations..."
docker exec gamearray ./manage.py migrate

echo "==> Copying static files..."
sudo docker cp gamearray:/src/static/. /var/www/gamearray/static/

echo "==> Deploy complete."

Why: A single script the pipeline calls via SSH. Keeps the pipeline YAML clean and lets us test deploys manually too (ssh staging /opt/gamearray/deploy.sh).

2c. Update infra/deploy-playbook.yaml -- add env file, deploy script, and registry login

Add these tasks (after the existing "Read secret key" task):

  1. Create /opt/gamearray/ directory on staging
  2. Template gamearray.env.j2 to /opt/gamearray/gamearray.env (mode 0600)
  3. Template deploy.sh.j2 to /opt/gamearray/deploy.sh (mode 0755)
  4. Docker login to Gitea registry -- so the deploy script can docker pull

For the Docker login, use a Gitea access token (created in the browser, stored in the staging vault):

- name: Login to Gitea container registry
  ansible.builtin.command:
    cmd: docker login gitea.earthmanrpg.me -u discoman -p {{ gitea_registry_token }}
  no_log: true

Vault additions needed for staging (infra/group_vars/staging/vault.yaml):

  • gitea_registry_token -- a Gitea personal access token with package:read scope

2d. Run the deploy playbook

ansible-playbook infra/deploy-playbook.yaml -i infra/inventory.ini -l staging --ask-vault-pass

This sets up the staging server: env file, deploy script, and registry auth.


Stage 3: Woodpecker secrets

Two secrets need to be added via the Woodpecker UI (https://ci.earthmanrpg.me -> repo settings -> Secrets):

3a. deploy_ssh_key

Generate a dedicated SSH key pair for CD:

ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519_cd_deploy -C "woodpecker-cd"
  • Add the public key to ~/.ssh/authorized_keys on staging (as discoman user)
  • Paste the private key contents into a Woodpecker secret named deploy_ssh_key

3b. gitea_registry_password

The Gitea access token (same one from Stage 2c, or a separate one with package:read,package:write scope).

  • Paste into a Woodpecker secret named gitea_registry_password
  • The build-and-push step will use this to docker login + docker push

Stage 4: Update .woodpecker.yaml

Add two new steps after screendumps:

  - name: build-and-push
    image: docker:cli
    environment:
      REGISTRY_PASSWORD:
        from_secret: gitea_registry_password
    commands:
      - echo "$REGISTRY_PASSWORD" | docker login gitea.earthmanrpg.me -u discoman --password-stdin
      - docker build -t gitea.earthmanrpg.me/discoman/gamearray:latest .
      - docker push gitea.earthmanrpg.me/discoman/gamearray:latest
    when:
      - branch: main
      - event: push

  - name: deploy
    image: alpine
    environment:
      SSH_KEY:
        from_secret: deploy_ssh_key
    commands:
      - apk add --no-cache openssh-client
      - mkdir -p ~/.ssh
      - printf '%s\n' "$SSH_KEY" > ~/.ssh/id_ed25519
      - chmod 600 ~/.ssh/id_ed25519
      - ssh -o StrictHostKeyChecking=no discoman@staging.earthmanrpg.me /opt/gamearray/deploy.sh
    when:
      - branch: main
      - event: push

Why docker:cli instead of a Docker plugin? Using plain Docker CLI with the socket pass-through is more transparent than a third-party plugin. You can see exactly what's happening.

Why alpine + manual SSH instead of appleboy/drone-ssh? Same reason -- fewer moving parts, easier to debug. The printf + ssh pattern is dead simple.

Branch gating: Both steps only run on pushes to main. Feature branch pushes still run tests but don't deploy.


Stage 5: Test end-to-end

  1. Push a small visible change (e.g., tweak a template's page title) to main on Gitea
  2. Watch the Woodpecker pipeline at https://ci.earthmanrpg.me:
    • test-UTs passes
    • test-FTs passes
    • build-and-push builds the image and pushes to the registry
    • deploy SSHes to staging and runs the deploy script
  3. Visit https://staging.earthmanrpg.me and verify the change is live

Files modified

File Action
infra/cicd/docker-compose.yaml Add WOODPECKER_BACKEND_DOCKER_VOLUMES to agent
infra/gamearray.env.j2 New -- container env file template
infra/deploy.sh.j2 New -- deploy script template
infra/deploy-playbook.yaml Add tasks for env file, deploy script, registry login
infra/group_vars/staging/vault.yaml Add gitea_registry_token
.woodpecker.yaml Add build-and-push and deploy steps

Known limitation

Ephemeral database: The SQLite database still lives inside the container (deferred issue from memory). Every deploy wipes it. This is fine for staging right now but will need a volume mount or PostgreSQL before production CD.