Skip to content

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-action logs in with ${{ github.actor }} and the workflow's GITHUB_TOKEN. The deploy job grants packages: write so it can push.
  • On the server, deploy.sh logs in with the GITHUB_ACTOR / GITHUB_TOKEN values 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.