Deploying a Docker Compose Application
Not every application needs Kubernetes. Many teams run a handful of containers — a web service, a worker, a database — on one or two hosts using Docker Compose. This lesson builds a GitHub Actions pipeline that builds an image, pushes it to a registry, and deploys it to a remote host running docker compose, with a health check gate and a rollback path. It assumes you already know how to write a basic workflow and how jobs, steps, and secrets work.
Overview / How it works
A Compose deployment pipeline has two halves. The build half runs entirely inside the GitHub-hosted runner: it builds a container image and pushes it to a registry such as GHCR or Docker Hub, producing an immutable content digest. The deploy half connects to your target host, usually over SSH, and tells the Docker Compose CLI already installed there to pull the new image and recreate the affected services. GitHub Actions never runs your containers itself in this model — it only issues remote commands.
This is worth naming precisely against the CI/CD vocabulary: building and testing the image on every push is continuous integration. Producing a deployable, digest-pinned image as a release artifact is continuous delivery — the software is always in a deployable state, but a human or a gate decides when it ships. When the workflow automatically runs the deploy job the moment the build and tests pass, with no manual approval, that is continuous deployment. The examples below use a GitHub environment with protection rules, which lets you choose either delivery (require a reviewer) or deployment (auto-promote) without changing the YAML structure.
Syntax or workflow structure
A Compose deploy workflow generally has this shape:
- A trigger, usually
pushto your main branch or a tag. - An explicit
permissions:block scoped to only what the job needs. - A
concurrency:group so two deploys can never race against each other on the same host. - A build-and-push job that outputs the resulting image digest.
- A deploy job gated by an
environment:, which reads secrets scoped to that environment and can require reviewer approval. - Steps inside the deploy job that copy the Compose file to the host, write an environment file with the digest-pinned image reference, and run
docker compose up -dremotely. - A health check step and a rollback step that only runs if the health check fails.
| Reference type | Example | Deploy behavior |
|---|---|---|
| Tag | web:latest |
Mutable — can silently change after deploy; hard to know what is actually running |
| Digest | web@sha256:af92... |
Immutable — always resolves to the exact bytes that were built and tested |
Examples
Example 1: Compose file with an image variable. The Compose file itself never hard-codes an image tag. It reads a variable that the deploy step supplies at deploy time, and it declares a health check so Docker can report container status.
services:
web:
image: ${WEB_IMAGE}
restart: unless-stopped
ports:
- "8080:8080"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/healthz"]
interval: 10s
timeout: 5s
retries: 5
db:
image: postgres:16.4
restart: unless-stopped
volumes:
- db-data:/var/lib/postgresql/data
volumes:
db-data:
Expected behavior: this file alone does nothing until WEB_IMAGE is set and docker compose up -d is run. The db service uses a pinned Postgres minor version, not latest, so it never changes unexpectedly.
Example 2: Minimal build-and-deploy workflow. This builds the image, pushes it to GHCR, captures the resulting digest, then deploys it over SSH.
name: Deploy Compose App
on:
push:
branches: [main]
permissions:
contents: read
packages: write
concurrency:
group: production-deploy
cancel-in-progress: false
jobs:
build-and-push:
runs-on: ubuntu-latest
outputs:
digest: ${{ steps.push.outputs.digest }}
steps:
- uses: actions/checkout@v4
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- id: push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ghcr.io/${{ github.repository }}/web:${{ github.sha }}
deploy:
needs: build-and-push
runs-on: ubuntu-latest
environment: production
permissions:
contents: read
deployments: write
steps:
- uses: actions/checkout@v4
- name: Copy compose file to host
uses: appleboy/scp-action@v0.1.7
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
source: "docker-compose.prod.yml"
target: "/opt/app"
- name: Deploy
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
script: |
cd /opt/app
echo "WEB_IMAGE=ghcr.io/${{ github.repository }}/web@${{ needs.build-and-push.outputs.digest }}" > .env
docker compose -f docker-compose.prod.yml pull
docker compose -f docker-compose.prod.yml up -d --remove-orphans
Expected behavior: every push to main builds a new image, waits for the production environment’s protection rules (for example, a required reviewer) to clear, then replaces the running web container with the exact digest that was just built. The database volume is untouched because db was not rebuilt.
Example 3: Add a health check gate and rollback. Before deploying, the workflow records which digest is currently running so it has something to roll back to if the new one fails its health check.
- name: Deploy with health check and rollback
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
script: |
cd /opt/app
PREVIOUS_IMAGE=$(docker compose -f docker-compose.prod.yml images -q web)
echo "WEB_IMAGE=ghcr.io/${{ github.repository }}/web@${{ needs.build-and-push.outputs.digest }}" > .env
docker compose -f docker-compose.prod.yml pull
docker compose -f docker-compose.prod.yml up -d --remove-orphans
for i in $(seq 1 10); do
STATUS=$(docker inspect --format='{{.State.Health.Status}}' app-web-1 || echo starting)
if [ "$STATUS" = "healthy" ]; then
echo "Deploy succeeded"
exit 0
fi
sleep 5
done
echo "Health check failed, rolling back"
echo "WEB_IMAGE=$PREVIOUS_IMAGE" > .env
docker compose -f docker-compose.prod.yml up -d
exit 1
Expected behavior: if the new container never reports healthy within roughly fifty seconds, the script writes the previous image reference back into .env, redeploys it, and fails the job so the pipeline reflects the true outcome instead of reporting a false success.
Step by step
- Write the application’s
docker-compose.prod.ymlusing an image variable, not a hard-coded tag, and add ahealthcheckto every service you plan to gate on. - Create a GitHub environment named
productionin repository settings, moveDEPLOY_HOST,DEPLOY_USER, andDEPLOY_SSH_KEYinto that environment’s secrets, and add required reviewers if you want delivery instead of full deployment. - Add a build job that logs in to your registry with a short-lived token and pushes the image, capturing the digest as a job output.
- Add a deploy job that depends on the build job, targets the
productionenvironment, and copies the Compose file to the host. - Have the remote script write the digest into an
.envfile, then rundocker compose pullfollowed bydocker compose up -d. - Poll the container’s health status after deploying; only report success once it reports healthy.
- On failure, redeploy the digest that was running before this deploy started, and let the job exit non-zero so on-call is notified.
Common Mistakes
Mistake 1: Deploying by mutable tag. A workflow that pushes and pulls web:latest looks correct until two deploys land close together, or until someone runs docker compose pull manually on the host. There is no reliable way to know which build is actually running, and there is nothing to roll back to. Fix: always resolve to the immutable digest produced by the build step, as in Example 2 and 3, and never reference :latest in a production Compose file.
Mistake 2: Giving every push to main direct production access. A workflow with no environment: key and repository-wide secrets means any merged commit — including one from a compromised dependency update or a mis-reviewed pull request — can immediately deploy using the SSH key. Fix: put deploy secrets in an environment rather than repository secrets, require a reviewer or a wait timer on that environment, and never trigger the deploy job from pull_request or pull_request_target on untrusted branches; a fork’s workflow content should never run with access to deploy credentials.
Best Practices
- Grant the workflow only
contents: read,packages: write, anddeployments: write— nothing broader, and neverwrite-all. - Reference third-party actions by a pinned version or commit SHA; a floating tag can change behavior or be compromised without any change to your own repository.
- Store the SSH private key and host details as environment-scoped secrets tied to a dedicated deploy user with the minimum filesystem and sudo access needed to run
docker compose. - Serialize deploys with a
concurrencygroup so a second push cannot start a deploy while one is already recreating containers. - Always deploy by digest, and keep the previous digest available so a failed health check can trigger an automatic rollback.
- Version the Compose file in the repository; treat any manual edit made directly on the host as drift to be corrected, not as the source of truth.
- Run
docker compose configlocally or in CI before deploying to catch syntax errors before they reach the host.
Practice Exercises
- Add a
stagingenvironment that deploys on every push to adevelopbranch with no required reviewers, whileproductionkeeps a manual approval gate. - Extend the health check loop to also verify the
dbservice is accepting connections before marking the deploy successful. - Add a step that fails the workflow if
docker compose config --quietreports an error, before any files are copied to the host. - Rewrite the rollback step as a separate, manually triggered
workflow_dispatchjob that redeploys a digest supplied as an input, for use when a bad deploy is discovered after the original workflow has finished.
Summary
A Docker Compose deployment pipeline separates cleanly into a build half that produces an immutable, digest-pinned image and a deploy half that instructs a remote host to run it. Scoping permissions and secrets to a protected environment turns an unreviewed continuous-deployment pipeline into one you control precisely, and a health check with a recorded previous digest turns a risky one-way push into a deploy with a real rollback path. The same shape — build, push by digest, deploy behind a gate, verify, roll back on failure — is the foundation the rest of this section builds on for more complex targets.
