DevOps Tutorial Intermediate

How to Build a GitHub Actions Deployment Pipeline

Test, build a container image, push it to GHCR and deploy it to a VPS over SSH — with the concurrency guard, environment approval and token scoping that keep the pipeline from becoming the weak point.

AI-assisted draft

The first draft of this article was generated with AI, then fact-checked, edited and verified by a human editor before publication.

A three-stage GitHub Actions pipeline running test, build and deploy jobs

Tested with

Runner
ubuntu-24.04 GitHub-hosted
Docker
27.x on the target host
Registry
ghcr.io
Bun
1.3.9
Target
Ubuntu 24.04 LTS VPS

Before you start

  • A repository with a Dockerfile that builds locally
  • A VPS running Docker, reachable over SSH
  • Permission to add repository secrets and environments
On this page

Three jobs, chained: test, build and push, deploy. Each one only runs if the previous passed, and the thing that gets deployed is the exact image that was tested.

The pipeline below deploys a Docker Compose stack to a single VPS. It is the shape that fits most self-hosted applications, and it takes about half an hour to set up.

Requirements

  • A Dockerfile that builds locally with docker build ..
  • A VPS running Docker with a compose.yaml in a fixed path — /opt/app here.
  • A dedicated deploy user on that host, in the docker group.

Generate a deploy key that exists only for this purpose:

bash
ssh-keygen -t ed25519 -C 'github-actions-deploy' -f ./deploy_key -N ''
ssh-copy-id -i ./deploy_key.pub deploy@203.0.113.42

The private key goes into a GitHub environment secret. Deleting the local copy afterwards is the point of generating it locally.

Why three jobs rather than one

Each job runs on a fresh runner with an empty filesystem, so splitting has a cost: anything the next job needs must be published somewhere — a registry, an artifact, a job output.

It buys three things worth more than that cost.

Failures are attributable. A red build next to a green test tells you the problem is the Dockerfile, not the code, before you open a single log.

Permissions can be scoped per job. test runs third-party dependencies and gets a read-only token; only build can write packages; only deploy can read the production credentials. In one combined job, everything has everything.

Approval has somewhere to attach. A GitHub Environment gates a job, not a step, so the deploy pausing for a reviewer requires deploy to be its own job.

The one thing the split does not buy is speed. Three jobs are usually slower than one, because each pays checkout and dependency installation again. Caching recovers most of that; correctness is worth the rest.

The test job

.github/workflows/deploy.yml
name: deploy

on:
  push:
    branches: [main]
  workflow_dispatch:

concurrency:
  group: deploy-production
  cancel-in-progress: false

permissions:
  contents: read

jobs:
  test:
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/checkout@v5
      - uses: oven-sh/setup-bun@v2
        with:
          bun-version: 1.3.9
      - run: bun install --frozen-lockfile
      - run: bun run lint
      - run: bun run typecheck
      - run: bun test

--frozen-lockfile fails the build if the lockfile does not match package.json, which is what you want in CI. A pipeline that silently resolves a different dependency tree than your machine is not testing your application.

concurrency at the workflow level with cancel-in-progress: false queues a second push behind the first. Cancelling mid-deploy would be worse than waiting.

Build and push the image

.github/workflows/deploy.yml
  build:
    needs: test
    runs-on: ubuntu-24.04
    permissions:
      contents: read
      packages: write
    outputs:
      image: ${{ steps.meta.outputs.tags }}
    steps:
      - uses: actions/checkout@v5

      - uses: docker/setup-buildx-action@v3

      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - id: meta
        uses: docker/metadata-action@v5
        with:
          images: ghcr.io/${{ github.repository }}
          tags: |
            type=sha,format=long

      - uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

permissions is declared per job, not globally. The test job has no reason to write packages, and scoping it here means a compromised dependency in the test step cannot push an image.

cache-from: type=gha uses GitHub’s own cache backend for Buildx layers. On a typical Node or Bun image this takes repeat builds from three minutes to under one.

type=sha,format=long produces a tag like ghcr.io/you/app:sha-9f2c1b4e8a.... That is what the deploy job uses.

Deploy over SSH

.github/workflows/deploy.yml
  deploy:
    needs: build
    runs-on: ubuntu-24.04
    environment:
      name: production
      url: https://app.example.com
    steps:
      - name: Configure SSH
        run: |
          install -m 700 -d ~/.ssh
          printf '%s\n' "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/id_ed25519
          chmod 600 ~/.ssh/id_ed25519
          printf '%s\n' "${{ secrets.DEPLOY_KNOWN_HOSTS }}" > ~/.ssh/known_hosts

      - name: Deploy the built image
        env:
          IMAGE: ${{ needs.build.outputs.image }}
        run: |
          ssh deploy@${{ secrets.DEPLOY_HOST }} \
            "cd /opt/app && \
             IMAGE_TAG='${IMAGE}' docker compose pull && \
             IMAGE_TAG='${IMAGE}' docker compose up -d --wait --wait-timeout 120"

      - name: Smoke test
        run: |
          for i in $(seq 1 10); do
            code=$(curl -s -o /dev/null -w '%{http_code}' https://app.example.com/healthz)
            [ "$code" = "200" ] && exit 0
            sleep 6
          done
          echo "health check never returned 200"
          exit 1

Two details do the heavy lifting.

--wait makes docker compose up block until the containers report healthy, so the step fails if the new version does not start. Without it the command returns as soon as the container is created and the pipeline goes green on a crash-looping deploy.

DEPLOY_KNOWN_HOSTS is a pinned host key rather than an ssh-keyscan at deploy time. Scanning at deploy time trusts whatever answers, which defeats the purpose. Generate it once:

bash
ssh-keyscan -t ed25519 203.0.113.42

Paste the output into the secret.

Add a required reviewer to that environment in Settings → Environments. The deploy job then pauses for approval, and an accidental merge to main becomes a notification rather than a release.

The compose file on the host

/opt/app/compose.yaml
services:
  app:
    image: ${IMAGE_TAG:-ghcr.io/you/app:latest}
    restart: unless-stopped
    ports:
      - "127.0.0.1:3000:3000"
    healthcheck:
      test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
      interval: 10s
      timeout: 3s
      retries: 3
      start_period: 20s

The health check is not optional here — --wait has nothing to wait for without it.

The host also needs to be able to pull from GHCR. For a private package, log in once with a read-only personal access token:

bash
echo "$GHCR_READ_TOKEN" | docker login ghcr.io -u your-username --password-stdin

Verify and roll back

bash
gh run watch
gh run view --log-failed

Rolling back is redeploying an earlier tag, which is why SHA tags matter:

bash
ssh deploy@203.0.113.42 \
  "cd /opt/app && IMAGE_TAG=ghcr.io/you/app:sha-<previous> docker compose up -d --wait"

The rollback that works is the one you have run before. Do it once on a quiet afternoon, from the previous tag, and confirm the site comes back. A procedure first executed during an incident is a hypothesis.

Keep a workflow_dispatch input for the tag so a rollback is a button rather than an SSH session:

.github/workflows/deploy.yml
on:
  workflow_dispatch:
    inputs:
      image_tag:
        description: Image tag to deploy (blank = build from this commit)
        required: false

Common errors

denied: permission_denied: write_package — the job is missing permissions: packages: write, or the repository’s default workflow token is read-only under Settings → Actions → Workflow permissions.

Host key verification failed.known_hosts is empty or has the wrong key. Regenerate with ssh-keyscan and check you copied the whole line.

Load key "/home/runner/.ssh/id_ed25519": error in libcrypto — the private key secret lost its trailing newline or was pasted as a single line. Copy the whole file including the -----BEGIN/-----END lines, and use printf '%s\n' rather than echo when writing it.

unauthorized: authentication required when the server pulls — the host is not logged in to GHCR, or the package is private and the token has expired.

The deploy step exits 0 but the site is broken. --wait is missing, or the service has no health check. Both fixes are above.

docker compose up reports dependency failed to start — a dependent service never became healthy. docker compose logs --tail 100 on the host tells you which.

Runs queue forever. Two workflows share a concurrency group and one is waiting on environment approval. Check the pending review.

Security notes

Pin third-party actions to a commit SHA. A tag is mutable, and an action runs with access to the secrets available to its job:

yaml
# Take the SHA from the release you actually reviewed, and keep the
# human-readable version in a trailing comment so the diff stays meaningful.
- uses: docker/build-push-action@<40-char-commit-sha> # v6.x

Enable Dependabot for github-actions so those pins get updated with a diff you can review.

Never use pull_request_target with a checkout of the pull request head. That combination runs untrusted code with write-scoped secrets and is the most commonly exploited GitHub Actions misconfiguration.

Restrict the deploy key on the server. In authorized_keys, prefix it with command="/opt/app/deploy.sh",no-agent-forwarding,no-port-forwarding,no-pty so the key can run exactly one script rather than any command.

Secrets are masked in logs, but only when they appear verbatim. A base64 or JSON-encoded secret is not masked — do not transform a secret and then print it.

Frequently asked questions

Should I build the image in CI or on the server?

In CI. Building on the target host consumes the memory and CPU your application needs, and it means the artifact that ships was never tested — you tested a different build. Build once, tag by commit SHA, deploy that exact tag.

Why tag images with the commit SHA instead of latest?

Because `latest` is not a version. With a SHA tag you can say exactly what is running, roll back by redeploying a previous tag, and reproduce a report against the same image. Push `latest` as well if you like, but deploy the SHA.

Is SSHing from a GitHub runner safe?

It is acceptable with a dedicated deploy key restricted by `command=` in `authorized_keys`, a `known_hosts` entry so the host is verified, and the key stored in an environment secret rather than a repository secret. A pull-based deploy where the server polls the registry avoids inbound access entirely and is stronger if you can arrange it.

How do I stop two deploys running at once?

A `concurrency` group at the workflow level with `cancel-in-progress: false`. The second run queues until the first finishes, rather than both writing to the host simultaneously.

Do I need to pin third-party actions to a commit SHA?

For anything outside your own organisation, yes. A tag is mutable — the author can move `v3` to new code at any time, and that code runs with access to your secrets. Pin the SHA and let Dependabot bump it.

Sources

  1. GitHub Actions documentation GitHub Primary source Accessed 16 Jul 2026
  2. Workflow syntax for GitHub Actions GitHub Primary source Accessed 16 Jul 2026
  3. Security hardening for GitHub Actions GitHub Primary source Accessed 16 Jul 2026
  4. Introduction to GitHub Actions with Docker Docker Primary source Accessed 16 Jul 2026
  5. docker/build-push-action Docker Primary source Accessed 16 Jul 2026
4 min read

Part of a learning path

  • Security intermediate

    How to Secure a VPS That Runs Docker Containers

    SSH hardening, a default-deny firewall, and the fix for the problem that catches almost everyone — Docker publishing container ports straight past UFW into the public internet.

    4 min
  • Docker intermediate

    How to Back Up Docker Volumes Automatically

    A nightly backup for a self-hosted Docker host: database dumps taken with the right tools, file volumes snapshotted with restic, a systemd timer to run it, and a restore test that proves it works.

    4 min AI-assisted