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
Dockerfilethat builds locally withdocker build .. - A VPS running Docker with a
compose.yamlin a fixed path —/opt/apphere. - A dedicated
deployuser on that host, in thedockergroup.
Generate a deploy key that exists only for this purpose:
ssh-keygen -t ed25519 -C 'github-actions-deploy' -f ./deploy_key -N ''
ssh-copy-id -i ./deploy_key.pub deploy@203.0.113.42The 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
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
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=maxpermissions 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
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 1Two 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:
ssh-keyscan -t ed25519 203.0.113.42Paste 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
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: 20sThe 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:
echo "$GHCR_READ_TOKEN" | docker login ghcr.io -u your-username --password-stdinVerify and roll back
gh run watch
gh run view --log-failedRolling back is redeploying an earlier tag, which is why SHA tags matter:
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:
on:
workflow_dispatch:
inputs:
image_tag:
description: Image tag to deploy (blank = build from this commit)
required: falseCommon 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:
# 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.xEnable 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.