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 push to 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 -d remotely.
  • 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

  1. Write the application’s docker-compose.prod.yml using an image variable, not a hard-coded tag, and add a healthcheck to every service you plan to gate on.
  2. Create a GitHub environment named production in repository settings, move DEPLOY_HOST, DEPLOY_USER, and DEPLOY_SSH_KEY into that environment’s secrets, and add required reviewers if you want delivery instead of full deployment.
  3. 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.
  4. Add a deploy job that depends on the build job, targets the production environment, and copies the Compose file to the host.
  5. Have the remote script write the digest into an .env file, then run docker compose pull followed by docker compose up -d.
  6. Poll the container’s health status after deploying; only report success once it reports healthy.
  7. 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, and deployments: write — nothing broader, and never write-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 concurrency group 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 config locally or in CI before deploying to catch syntax errors before they reach the host.

Practice Exercises

  • Add a staging environment that deploys on every push to a develop branch with no required reviewers, while production keeps a manual approval gate.
  • Extend the health check loop to also verify the db service is accepting connections before marking the deploy successful.
  • Add a step that fails the workflow if docker compose config --quiet reports an error, before any files are copied to the host.
  • Rewrite the rollback step as a separate, manually triggered workflow_dispatch job 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.