Skip to main content
DevOps from zero · Lesson 6 of 8 · 14 min

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.
Watch it work

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.

Step 1 of 7. 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]

Keys: ← → step · Home / End · K play or pause

  1. 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]
  2. 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)
  3. 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: verify
  4. build-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.io
  5. deploy 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.3
  6. The 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)
  7. 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)

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)

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)

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)

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.

How this site does it

The real config behind this lesson

Excerpts from the repository that builds and deploys this website, quoted as they are.

.github/workflows/deploy.yml:42-54
  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.

.github/workflows/deploy.yml:185-199
  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: read

The 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.

.github/workflows/deploy.yml:104-129
      - 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=max

The 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.

.github/workflows/deploy.yml:87-97
      - 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.

.github/workflows/deploy.yml:136-150
      # 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=gha

The 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.

.github/workflows/deploy.yml:224-236
            # 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-stdin

The 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.

What breaks

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.

Builds and the deploy: two concurrency groups
Groupbuild-${{ github.ref }}deploy-vps
cancel-in-progresstruefalse
A newer push arrivesThe running build is cancelledIt waits; the running deploy always finishes
WhySuperseded work is wasteA half-finished deploy can leave the server between images
Try it yourself

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.

  1. 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.js
  2. Start 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:$TAG
  3. Deploy 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 --wait
  4. Break 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
Quick quiz

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.

  1. 1.To stop stale builds piling up, you put the whole workflow in one concurrency group with cancel-in-progress: true. What new risk did you add?
  2. 2.A cache-export step runs inside build-and-push after the image is pushed, without continue-on-error. The step fails. What happens to the deploy?
  3. 3.A GITHUB_TOKEN value ends up in an old job's log. Could someone use it next week to push images to ghcr.io?
Think it through

Answer in your head, then check

  1. 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. 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. 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. 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.

Go deeper

Related deep dives on this site