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):
- Create
/opt/gamearray/directory on staging - Template
gamearray.env.j2to/opt/gamearray/gamearray.env(mode 0600) - Template
deploy.sh.j2to/opt/gamearray/deploy.sh(mode 0755) - 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 withpackage:readscope
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_keyson staging (asdiscomanuser) - 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-pushstep will use this todocker 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
- Push a small visible change (e.g., tweak a template's page title) to
mainon Gitea - Watch the Woodpecker pipeline at
https://ci.earthmanrpg.me:test-UTspassestest-FTspassesbuild-and-pushbuilds the image and pushes to the registrydeploySSHes to staging and runs the deploy script
- Visit
https://staging.earthmanrpg.meand 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.