Docker & GHCR¶
Application images are built by CI, published to the GitHub Container Registry (GHCR), and pulled by tag on the deploy server. This page describes that container/registry model end to end: authentication, build caching, the compose pull policy, the split between the server's .env and the pipeline's .env.deploy, and image cleanup.
Where images live¶
All images are published to ghcr.io under the org namespace:
| Workflow | Images |
|---|---|
Test (deploy.yml) |
ghcr.io/<org>/app-name-backend and ghcr.io/<org>/app-name-frontend, each tagged :latest + :<sha> |
Production (deploy-production.yml) |
A single ghcr.io/<org>/app-name, tagged :latest + :<tag> |
The deploy server pulls images by tag, selected through the IMAGE_TAG environment variable. In test that tag is the commit SHA; in production it is the version tag.
Authentication¶
GHCR is authenticated in two places, both with a GitHub token:
- In CI,
docker/login-actionlogs in with${{ github.actor }}and the workflow'sGITHUB_TOKEN. The deploy job grantspackages: writeso it can push. - On the server,
deploy.shlogs in with theGITHUB_ACTOR/GITHUB_TOKENvalues passed in through.env.deploy:
echo "$GITHUB_TOKEN" | docker login ghcr.io -u "$GITHUB_ACTOR" --password-stdin
Note
The server only needs read (pull) access to GHCR. The token injected at deploy time is the workflow's ephemeral GITHUB_TOKEN, so no long-lived registry credentials are stored on the box.
Build cache (buildx + type=gha)¶
Builds use Docker Buildx with the GitHub Actions cache backend (type=gha). The test workflow keeps separate cache scopes for each image so an unrelated change to one does not invalidate the other:
cache-from: type=gha,scope=backend
cache-to: type=gha,mode=max,scope=backend
# frontend uses scope=frontend
The production workflow builds a single image and therefore uses a single, unscoped type=gha cache. mode=max caches all intermediate layers for the best hit rate on the next run.
Pull policy¶
The deploy script always forces a fresh pull so the server can never silently run a stale local image of a moving tag:
docker compose pull --policy always
This matters most for :latest and for re-deploys of the same tag — --policy always re-resolves the digest from GHCR rather than trusting whatever is cached locally.
Server-side .env vs .env.deploy¶
Two distinct files supply environment to the server, and they have very different lifecycles:
| File | Origin | Lifetime | Contents |
|---|---|---|---|
.env |
Managed manually on the server | Permanent | Runtime secrets: DATABASE_URL, session secret, API keys, etc. Excluded from every rsync. |
.env.deploy |
Generated by the pipeline each run | Deleted after it is sourced | Deploy-time values: IMAGE_TAG, REGISTRY_OWNER, GITHUB_TOKEN, GITHUB_ACTOR, ENV. |
deploy.sh refuses to run if .env is missing, then sources .env.deploy line by line and removes it:
[ -f .env ] || { echo "❌ .env missing"; exit 1; }
if [ -f .env.deploy ]; then
while IFS= read -r line || [ -n "$line" ]; do
case "$line" in [A-Za-z0-9_]*=*) export "$line" ;; esac
done < .env.deploy
rm .env.deploy
fi
Never commit or rsync .env
The runtime .env is the source of truth for secrets and lives only on the server. Every rsync in both workflows uses --exclude='.env' so the pipeline cannot overwrite it.
Example docker-compose.yml¶
A minimal compose file that pulls the backend, frontend, and an nginx reverse proxy from GHCR by tag. IMAGE_TAG is supplied by the deploy environment; REGISTRY_OWNER lets the same file work across forks/orgs.
services:
backend:
image: ghcr.io/${REGISTRY_OWNER:-<org>}/app-name-backend:${IMAGE_TAG:-latest}
env_file: .env
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/api/health"]
interval: 10s
timeout: 5s
retries: 5
frontend:
image: ghcr.io/${REGISTRY_OWNER:-<org>}/app-name-frontend:${IMAGE_TAG:-latest}
env_file: .env
restart: unless-stopped
depends_on:
- backend
nginx:
image: nginx:alpine
restart: unless-stopped
ports:
- "80:80"
volumes:
- ./nginx/conf.d:/etc/nginx/conf.d:ro
depends_on:
- frontend
- backend
Single-image production
For the production single-image shape, replace the backend/frontend services with one app service pulling ghcr.io/${REGISTRY_OWNER:-<org>}/app-name:${IMAGE_TAG:-latest}. The deploy script's migrate and sync-manual steps already run against a service named app.
Image cleanup and the disk guard¶
Disk fills up fast when every deploy pulls new image layers, so deploy.sh prunes around each deploy and escalates when the disk gets tight:
docker image prune -f # pre-deploy: drop dangling images
DISK_USAGE=$(df / --output=pcent | tail -1 | tr -dc '0-9')
if [ "$DISK_USAGE" -gt 80 ]; then # disk-usage guard
docker system prune -af --volumes # aggressive: all unused images + volumes
fi
# ... deploy ...
docker image prune -f # post-deploy cleanup
docker system prune -f
| Command | Removes |
|---|---|
docker image prune -f |
Dangling (untagged) images only |
docker system prune -f |
Stopped containers, unused networks, dangling images, build cache |
docker system prune -af --volumes |
All unused images and volumes — only triggered above 80% disk |
The 80% guard can remove unused volumes
docker system prune -af --volumes deletes volumes not currently attached to a running container. Make sure databases and other stateful data live on bind mounts or named volumes that are in use — or, better, are backed up — before relying on this guard. See Rollback & Incidents.
Related: Deploy to Test · Deploy to Production · Secrets Matrix.