GitHub Actions Deploy to VPS: Build, Push to GHCR, SSH
Deploy to a VPS with GitHub Actions the way this site does: a verify gate, an image pushed to GHCR, then SSH and Docker Compose, with secrets kept safe.
By Matthews WongReviewed on
Key takeaway
A GitHub Actions deploy to a VPS is a workflow of chained jobs: verify the commit, build a Docker image and push it to a registry such as GHCR, then SSH to the server, write its settings from secrets and recreate the container. needs keeps a broken commit away from the server, and per-job concurrency keeps a deploy from being cut off.
You will be able to
- Chain verify, build-and-push and deploy with needs, and predict which jobs are skipped when one fails.
- Push an image to GHCR with the job's own GITHUB_TOKEN, and deploy that exact build by its commit SHA.
- Split concurrency so superseded builds cancel while a deploy is never cut off halfway.
Step through the diagram
Press Play, or step with the arrows. Every step is written out below the diagram.
How one push becomes a deploy
A push runs verify, then build-and-push, then deploy. A failing verify skips the rest; a passing run pushes the image to ghcr.io, and the VPS pulls it and recreates the container.
on:
push:
branches: [main]Keys: ← → step · Home / End · K play or pause
One push to main starts three jobs chained by needs. The image they build travels through ghcr.io to the container on your VPS.
on: push: branches: [main]verify runs npm ci, tsc --noEmit and lint. A type error fails it, every job that needs it is skipped, and the VPS keeps its last good image.
- run: npm ci - run: npx tsc --noEmit - run: npm run lintPredict the next step Without needs, when would build-and-push start? (After verify passes, by default · At once, alongside verify)Push the fix. verify passes, and build-and-push starts because it declares needs: verify. Without needs, all three jobs would start at once.
build-and-push: name: Build & push image needs: verifybuild-and-push logs in to ghcr.io with the job's own GITHUB_TOKEN, builds the image, and pushes two tags: latest and the commit SHA.
- name: Log in to GHCR uses: docker/login-action@v3 with: registry: ghcr.iodeploy needs build-and-push. Over SSH it copies docker-compose.yml, then writes .env from GitHub secrets. No app secret appears anywhere else in the workflow.
- name: Deploy over SSH uses: appleboy/ssh-action@v1.0.3The server logs in to ghcr.io, pulls the image by its SHA tag, and docker compose up -d recreates the container from it.
docker compose pull docker compose up -d --remove-orphansPredict the next step up -d has returned. Is the deploy job green yet? (Yes, before the health check passes · No, it waits for healthy first)up -d returns once the container starts, so the job turns green before the health check has passed. Adding --wait would make a failing release fail the run.
interval: 30s timeout: 5s retries: 3 start_period: 20s
How do needs and outputs chain the jobs in a GitHub Actions deploy?
Jobs in a workflow run in parallel by default. needs turns them into a chain: build-and-push declares needs: verify, and deploy declares needs: build-and-push. GitHub's rule is blunt: if a job fails or is skipped, all jobs that need it are skipped. That is the whole safety net, so a commit that fails its typecheck never produces an image, let alone reaches the server.
Read more (2)Show less
Jobs also pass values down the chain. build-and-push writes the full image reference, ghcr.io/<owner>/<repo>:<sha>, to $GITHUB_OUTPUT and exposes it as the job output image. deploy reads it as needs.build-and-push.outputs.image and writes it into .env as IMAGE, so the server pulls exactly the build that just passed, never whatever latest happens to point at. The reference is lowercased first, because registry repository names must be lowercase and a GitHub owner name may not be.
Permissions are set per job too. build-and-push asks for packages: write because it pushes; deploy asks only for packages: read because the server only pulls. A job that is compromised can then do no more than its own step needed.
Why should builds cancel but a deploy never cancel?
A concurrency group allows at most one running job and, by default, one pending job. When a new job joins the group, the pending one is cancelled; cancel-in-progress: true also cancels the one that is already running. That second option is exactly right for builds and exactly wrong for a deploy.
Read more (2)Show less
On 2026-09-24 this site had one workflow-level group with cancel-in-progress: false, and a burst of commits queued 28 runs, 27 of them building code that was already superseded. Flipping that one group to true would have cleared the queue, and also allowed a run to be cancelled in the middle of the SSH deploy, which can leave the server between two images.
The fix is two groups. verify and build-and-push share build-${{ github.ref }} with cancel-in-progress: true, so a newer push throws away stale work at once. deploy sits alone in deploy-vps with cancel-in-progress: false, so deploys wait their turn and always finish. A cancelled build never leaves a half deploy behind either: its deploy needs it, so the deploy is skipped before it can start.
How does the workflow log in to GHCR and keep secrets out of the repo?
GitHub Container Registry lives at ghcr.io, and a workflow can push to it with GITHUB_TOKEN, a token GitHub creates for each job. It expires when the job finishes or reaches its maximum lifetime, so there is no long-lived registry password to leak. build-and-push uses it through docker/login-action. The deploy job pipes its own token into docker login on the server, pulls, and runs docker logout at the end.
Read more (2)Show less
Application secrets never touch the repository or the image. They live as GitHub secrets and variables, and appear in exactly one place: the deploy step, which writes them into .env on the server through a quoted heredoc and then runs chmod 600 .env. docker-compose.yml reads that file both for the container's environment and for the IMAGE and APP_PORT substitutions.
Two limits are worth knowing. Secrets are not passed to workflows triggered from a fork, and GitHub says automatic redaction in logs is not guaranteed, because a transformed secret no longer matches the stored value. Never print a secret to the log, even one you expect to be masked.
How does the Next.js build cache survive a Docker build in CI?
This site's next build is incremental only when .next/cache from the last build is present. A hosted runner starts empty every time, so the job restores the folder with actions/cache. The key ends in the commit SHA, so it never hits exactly and every run saves a fresh entry, while restore-keys falls back to the most recent cache for the same lockfile, then to any cache at all.
Read more (2)Show less
The build itself runs inside Docker, so the cache has to make a round trip. A !.next/cache line in .dockerignore lets the restored folder ride into the build context. After the push, a second build-push-action run targets a cache-export stage and writes the grown cache back to the runner, where actions/cache saves it. A BuildKit cache mount looks simpler, but Docker's docs say BuildKit doesn't preserve cache mounts in the GitHub Actions cache by default.
Two guard rails keep the optimisation from hurting. The export step has continue-on-error: true, because a failed cache export must never block the deploy of an image that is already pushed. And the job saves nothing once .next/cache passes 1,500 MB, because a cache that is restored, grown and saved on every run otherwise only ever grows.
The real config behind this lesson
Excerpts from the repository that builds and deploys this website, quoted as they are.
build-and-push:
name: Build & push image
needs: verify
runs-on: ubuntu-latest
# Same group as verify: one supersede cancels the whole build chain.
concurrency:
group: build-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
packages: write
outputs:
image: ${{ steps.meta.outputs.image }}needs makes this job wait for verify, and it shares verify's concurrency group, so one newer push cancels the whole build chain. It is the only job with packages: write. Its image output is how deploy learns which exact build to run.
deploy:
name: Deploy to VPS
needs: build-and-push
runs-on: ubuntu-latest
# The one group that must NEVER cancel in progress. This step SSHes to the
# VPS and runs `docker compose pull && up -d`; interrupting it can leave the
# box between images. Concurrent deploys queue and run one at a time, which
# is the original intent — it is only the build chain above that should be
# throwing away superseded work.
concurrency:
group: deploy-vps
cancel-in-progress: false
permissions:
contents: read
packages: readThe other half of the split. deploy-vps never cancels a running deploy, so an SSH session is never cut off between pull and up. The job can read packages but not write them, which is all a server that only pulls needs.
- name: Compute lowercase image ref
id: meta
run: |
ref="$(echo "ghcr.io/${{ github.repository }}" | tr '[:upper:]' '[:lower:]')"
echo "ref=${ref}" >> "$GITHUB_OUTPUT"
echo "image=${ref}:${{ github.sha }}" >> "$GITHUB_OUTPUT"
- uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: |
${{ steps.meta.outputs.ref }}:latest
${{ steps.meta.outputs.ref }}:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=maxThe image name comes from github.repository, lowercased with tr, so nothing about the registry path is typed by hand. The login uses the job's GITHUB_TOKEN, not a stored password. Two tags go up: latest for humans, and the commit SHA, which the image output carries to deploy.
- name: Restore Next.js build cache
uses: actions/cache@v4
with:
path: .next/cache
# SHA in the key so every run writes a fresh entry; restore-keys then
# falls back to the most recent cache for the same lockfile, and
# finally to any cache at all, rather than starting cold.
key: next-cache-${{ hashFiles('package-lock.json') }}-${{ github.sha }}
restore-keys: |
next-cache-${{ hashFiles('package-lock.json') }}-
next-cache-An exact key hit would mean the cache is never saved again, so the key carries the SHA and is new on every run. restore-keys are prefixes: the newest cache for this lockfile wins, and any cache beats starting cold.
# Best-effort, because it is an optimisation and the image is already
# pushed by this point. It is meant to reuse every layer from the build
# above, but on 2026-10-03 BuildKit had dropped them: this step re-ran
# npm ci and the whole next build, then died with "EOF" exporting the
# 572 MB cache. That failed the job, and `needs: build-and-push` then
# skipped the deploy of an image that had built and pushed cleanly.
- name: Extract Next.js build cache
id: extract-next-cache
continue-on-error: true
uses: docker/build-push-action@v6
with:
context: .
target: cache-export
outputs: type=local,dest=/tmp/next-cache
cache-from: type=ghaThe return half of the round trip: build only the cache-export stage and write it to a local folder. The comment is the incident that made it best-effort. A later step checks steps.extract-next-cache.outcome, the result before continue-on-error is applied, and saves no cache when it failed.
# Write runtime env from GitHub Secrets/Variables. Quoted heredoc so
# shell does not expand anything in the (already-rendered) values.
cat > .env <<'ENVEOF'
GROQ_API_KEY=${{ secrets.GROQ_API_KEY }}
RESEND_API_KEY=${{ secrets.RESEND_API_KEY }}
CONTACT_FROM_EMAIL=${{ vars.CONTACT_FROM_EMAIL }}
CONTACT_TO_EMAIL=${{ vars.CONTACT_TO_EMAIL }}
IMAGE=${{ needs.build-and-push.outputs.image }}
APP_PORT=${{ vars.APP_PORT || 3002 }}
ENVEOF
chmod 600 .env
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdinThe only place the app's secrets appear. GitHub renders the expressions before the script runs, and the quoted 'ENVEOF' stops the shell from expanding a $ inside a value. IMAGE is the SHA-tagged reference from build-and-push. The token is piped to docker login, never passed as an argument.
Mistakes that cost real time
One concurrency group for the whole workflow
With cancel-in-progress: false, a burst of commits queues complete pipelines that build stale code. With true, a newer push can kill a deploy halfway through its SSH script. Give builds and deploys separate groups.
An optimisation that blocks the deploy
On 2026-10-03 the cache export died with EOF while exporting a 572 MB cache. The job failed, and needs skipped the deploy of an image that had already been pushed. Mark best-effort steps continue-on-error: true, and check their outcome where it matters.
A runner that runs out of disk
On 2026-10-07 deploys failed with no space left on device in the middle of the image build. The job now deletes toolchains it never uses before it builds, and prints df before and after, so the headroom shows in the log.
What I'd change: the deploy does not wait for health
docker compose up -d returns once the container has started, so a release that then fails its health check still leaves the run green. up -d --wait waits for running or healthy and exits non-zero otherwise. In my local test the unhealthy container kept serving, so --wait tells you about a bad release; rolling back is still your move.
What I'd change: tag-pinned SSH actions and no host key
appleboy/scp-action@v0.1.7 and appleboy/ssh-action@v1.0.3 are pinned to tags. GitHub's secure-use guide recommends a full commit SHA, because a tag can be moved or deleted. Neither step sets the fingerprint input either, which the action's README says helps prevent man-in-the-middle attacks.
| verify, build-and-push | deploy | |
|---|---|---|
| Group | build-${{ github.ref }} | deploy-vps |
| cancel-in-progress | true | false |
| A newer push arrives | The running build is cancelled | It waits; the running deploy always finishes |
| Why | Superseded work is waste | A half-finished deploy can leave the server between images |
Do it on your own machine
Rehearse the whole pipeline on your laptop with the devops-lab folder from the Docker lesson: a gate, a registry, a deploy by tag, and the difference --wait makes. You need Docker and Node, nothing else.
Run the gate the way needs does: each command starts only if the one before it succeeded. The second chain fails at the syntax check, so docker build never runs.
npm ci && node --check src/server.js && docker build -t devops-lab:v1 . echo "if (" > src/broken.js npm ci && node --check src/broken.js && docker build -t devops-lab:v2 . rm src/broken.jsStart a throwaway registry on port 5001 and push the image with two tags, like build-and-push does. A timestamp stands in for the commit SHA.
docker run -d -p 5001:5000 --name registry registry:3 TAG=$(date +%s) docker tag devops-lab:v1 localhost:5001/devops-lab:latest docker tag devops-lab:v1 localhost:5001/devops-lab:$TAG docker push localhost:5001/devops-lab:latest docker push localhost:5001/devops-lab:$TAGDeploy by the exact tag, the way the server does: write .env, pull, then start with --wait so the command returns only once the health check passes.
# compose.yaml services: web: image: ${IMAGE} env_file: .env ports: - "127.0.0.1:8080:3000" healthcheck: test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:3000/"] interval: 5s timeout: 3s retries: 3 # then, in the same folder printf 'IMAGE=localhost:5001/devops-lab:%s\n' "$TAG" > .env chmod 600 .env docker compose pull docker compose up -d --waitBreak the health check: change :3000/ in the test URL to :3999/. up -d still exits 0, while up -d --wait exits 1 after about 15 seconds, and the unhealthy container keeps answering. Clean up at the end.
docker compose up -d; echo "exit $?" docker compose down docker compose up -d --wait; echo "exit $?" curl -s http://127.0.0.1:8080/ docker compose down && docker rm -f registry
Three questions before you move on
Pick an answer to see why it is right or wrong. Nothing is saved or graded, so guess freely.
Answer in your head, then check
1.A commit fails npx tsc --noEmit. What happens to build-and-push and deploy, and what is the VPS serving?Show the answer
Answer:Both are skipped, because a job that needs a failed or skipped job is skipped too. The VPS keeps serving the last image that passed, since nothing new reached it.
2.Why does build-and-push use cancel-in-progress: true while deploy uses false?Show the answer
Answer:A superseded build is wasted work, so cancelling it costs nothing. A deploy cancelled mid-SSH can leave the server between two images, so deploys wait their turn instead.
3.Why does the server pull the SHA tag instead of latest?Show the answer
Answer:The SHA tag names exactly one build: the one that just passed verify. latest moves with every push, so it cannot tell you which build is running or bring an older one back.
4.The deploy job is green. Does that prove the new container is healthy?Show the answer
Answer:No. docker compose up -d returns once the container starts, before its health check has passed. Only --wait makes the command, and so the job, fail on an unhealthy service.
Related deep dives on this site
Sources
Primary sources checked on the research date. Follow them for the current version.
- GitHub docs: Workflow syntax (needs, outputs, continue-on-error)
- GitHub docs: Control the concurrency of workflows and jobs
- GitHub docs: GITHUB_TOKEN
- GitHub docs: Working with the Container registry
- GitHub docs: Using secrets in GitHub Actions
- GitHub docs: Secure use reference (pinning actions, redaction)
- GitHub docs: Dependency caching reference
- Docker docs: Cache management with GitHub Actions
- Docker docs: docker compose up (--wait, --remove-orphans)
- appleboy/ssh-action README (fingerprint input)
- Distribution docs: Deploy a registry server