Mert Özen Further With Every Line
DevOps GitHub Actions — #11

Deploying with GitHub Actions 2: Your Own Linux Server via SSH, Docker Images and GHCR

Mert Özen Nov 8, 2026 12 min 5 views
Deploying with GitHub Actions 2: Your Own Linux Server via SSH, Docker Images and GHCR

Build the image once in GitHub Actions, push it to GHCR and send only the digest to the server over SSH; the server pulls it with Docker Compose and restarts. metadata-action, build-push-action and the gha cache backend (28 s down to 4 s), actions/attest, pinning known_hosts and what happens when the host key changes, rollback with .env.previous; real runs with act.

Version note: The examples in this post were run with act 0.2.97, Docker 29.2 (Buildx 0.36.1), docker/build-push-action@v7, docker/login-action@v4, docker/setup-buildx-action@v4, registry 3, OpenSSH 9.6, Docker Compose 2.40 and .NET 10. The real push to GitHub Container Registry and the attestation were not run on GitHub; the differences are called out one by one in the post.

Guess First

The deploy job connects to the server over SSH (Secure Shell) and writes its known_hosts file from an environment secret. One night the hosting company reinstalls the server with the same IP (Internet Protocol) address. What does the deploy do the next morning: quietly connect to the new server and carry on, or fail? And what if the workflow had -o StrictHostKeyChecking=no?

Second question: you build and push the image twice for the same commit; the second build comes entirely from cache. Do the two images in the registry have the same digest?

The Goal: Build the Image Once, the Server Only Pulls

In the previous post the deploy target was App Service and the package was a zip. In this post the target is our own Linux server and the package is a container image. The flow: the CI (Continuous Integration) job builds the Bookstore.Api image, pushes it to GHCR (GitHub Container Registry) and exposes its digest as an output. The deploy job connects to the server over SSH, writes that digest into the Docker Compose setup on the server, pulls the image and restarts the container. No source code on the server, no build, no SDK (Software Development Kit). We covered the Dockerfile itself in the "Containerizing with Docker and Setting Up CI/CD with GitHub Actions" post of the ASP.NET Core Web API (Application Programming Interface) series, and its details are the topic of the upcoming Docker series; here api/Dockerfile is a ready-made multi-stage file that restores with --locked-mode.

The full workflow:

name: deploy-vm

on:
  push:
    branches: [main]
    tags: ['v*']

permissions:
  contents: read

env:
  IMAGE: ghcr.io/octo-org/bookstore-api

jobs:
  image:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
      id-token: write
      attestations: write
    outputs:
      image-ref: ${{ env.IMAGE }}@${{ steps.push.outputs.digest }}
    steps:
      - uses: actions/checkout@v7
      - id: meta
        uses: docker/metadata-action@v6
        with:
          images: ${{ env.IMAGE }}
          tags: |
            type=sha
            type=semver,pattern={{version}}
            type=semver,pattern={{major}}.{{minor}}
      - uses: docker/login-action@v4
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - uses: docker/setup-buildx-action@v4
      - id: push
        uses: docker/build-push-action@v7
        with:
          context: .
          file: api/Dockerfile
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha,scope=bookstore-api
          cache-to: type=gha,mode=max,scope=bookstore-api
      - if: startsWith(github.ref, 'refs/tags/v')
        uses: actions/attest@v4
        with:
          subject-name: ${{ env.IMAGE }}
          subject-digest: ${{ steps.push.outputs.digest }}
          push-to-registry: true

  deploy-staging:
    needs: image
    runs-on: ubuntu-latest
    environment:
      name: staging
      url: ${{ vars.APP_URL }}
    concurrency:
      group: deploy-staging
      cancel-in-progress: false
    steps:
      - name: Configure SSH
        env:
          SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
          KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }}
        run: |
          install -m 700 -d ~/.ssh
          printf '%s\n' "$SSH_KEY" > ~/.ssh/deploy_key
          chmod 600 ~/.ssh/deploy_key
          printf '%s\n' "$KNOWN_HOSTS" > ~/.ssh/known_hosts
      - name: Pull and restart on the server
        env:
          DEPLOY_HOST: ${{ vars.DEPLOY_HOST }}
          DEPLOY_PORT: ${{ vars.DEPLOY_PORT }}
          IMAGE_REF: ${{ needs.image.outputs.image-ref }}
        run: |
          ssh -i ~/.ssh/deploy_key -p "$DEPLOY_PORT" \
            -o StrictHostKeyChecking=yes -o BatchMode=yes \
            "deploy@$DEPLOY_HOST" bash -s -- "$IMAGE_REF" <<'EOF'
          set -euo pipefail
          cd /srv/bookstore
          cp .env .env.previous 2>/dev/null || true
          echo "BOOKSTORE_IMAGE=$1" > .env
          docker compose pull --quiet
          docker compose up -d --wait
          docker compose ps --format '{{.Name}} {{.Image}} {{.Status}}'
          EOF
      - name: Health check
        env:
          APP_URL: ${{ vars.APP_URL }}
        run: curl --fail --silent --show-error --retry 5 --retry-delay 3 --retry-all-errors "$APP_URL/health"

Tagging the Image: metadata-action

docker/metadata-action produces tags and OCI (Open Container Initiative) labels from the Git context. With our definition, every push to main gets a tag like sha-d4be9d0, made from the first seven characters of the commit SHA (Secure Hash Algorithm). When the v1.4.0 tag is pushed, 1.4.0 and 1.4 are added; since a semver type is used, the action's default latest=auto behavior adds latest as well. The labels include org.opencontainers.image.source, revision and created. The source label is what links the package on GHCR to the repository: the package inherits the repository's access permissions, but not its visibility. A package pushed to GHCR for the first time is private by default.

Tags are for readability; for deploying we use the digest. The image job's output looks like ghcr.io/octo-org/bookstore-api@sha256:.... Tags can move (latest on every release, 1.4 on every patch); a digest does not. We don't tell the server "pull this tag", we tell it "pull exactly these bytes"; it is the simplest way to prove that the image tested in staging is the one going to production.

I hit an obstacle here in the local run. Under act, metadata-action failed like this:

[deploy-vm/image]   ❗  ::error::Bad credentials - https://docs.github.com/rest
[deploy-vm/image]   ❌  Failure - Main docker/metadata-action@v6 [2.665411125s]

To fill in the labels, the action reads repository information from the GitHub API, and there is no valid token in act. Since I did not want to hand it a real token, in the local copy I replaced this step with a small run: step that writes tags and labels outputs in the same format. The tag and label behavior above comes from the action's README; the build, push and deploy output below comes from real runs.

Pushing to GHCR and the GitHub Actions Cache Backend

Logging in to GHCR needs no separate secret: when the job is granted packages: write, GITHUB_TOKEN can push to packages linked to the workflow's repository. The one exception is when a package with the same name already exists but is not linked to the repository; then the push is rejected and you have to grant the repository access in the package settings.

For the local run I used the official registry:3 container on localhost:18310 instead of GHCR; in the local copy of the workflow the image name and login registry were rewritten to that address, and driver-opts: network=host was added to setup-buildx so the BuildKit container could reach it. The first build took 28 seconds and at the end the cache was written to the GitHub Actions cache backend:

#7 importing cache manifest from gha:6229865950423264461
#18 exporting manifest list sha256:65e0822b607b1e09eb3daee0f548edd267994cbb68127b2057cb3d567db64dbf done
#18 pushing manifest for localhost:18310/octo-org/bookstore-api:sha-d4be9d0@sha256:65e0822b607b1e09eb3daee0f548edd267994cbb68127b2057cb3d567db64dbf
#19 exporting to GitHub Actions Cache
#19 preparing build cache for export 0.5s done
✅  Success - Main docker/build-push-action@v7 [28.264897167s]

Then I made a commit that only changed the README and ran the same workflow again. Since the Dockerfile only copies api/src, every layer came from cache:

#7 importing cache manifest from gha:16090413008912912096
#9 CACHED
#10 CACHED
...
#17 CACHED
✅  Success - Main docker/build-push-action@v7 [3.937505125s]

From 28 seconds to 4. The type=gha backend also worked against act's own cache server. On GitHub this cache takes space from the repository's Actions cache quota (10 GB by default; entries not accessed for seven days are evicted). mode=max also writes layers from intermediate stages; it is how you cache the SDK stage of a multi-stage Dockerfile, but it fills the quota quickly. Give each image its own scope; without it, the API and web images overwrite each other's cache. Buildx's gha backend only supports cache service API v2; v1 was shut down on April 15, 2025, so these lines will not work with a Buildx older than 0.21.

The answer to the second question is in this run too: even though every layer came from cache, the second image's digest was sha256:0909bf90..., while the first was sha256:65e0822b.... The revision label in the image config points to the new commit, and the provenance attestation manifest that build-push-action adds by default (the exporting attestation manifest line in the log) carries build-specific information. That is why the "same code, same digest" expectation does not hold; think of the digest as the identity of each build.

I also saw an act-specific warning: in its post step, build-push-action tries to upload the build record as an artifact, and act's artifact server rejects it because of the mime_type problem we saw in post 09. The step passes with a warning: ::warning::Failed to CreateArtifact: Failed to make request after 5 attempts: Unexpected end of JSON input. On GitHub this record shows up as a download in the run summary.

Artifact Attestation

The last step, on tag pushes, produces a signed build provenance for the image and writes it to the registry. actions/attest@v4 absorbed the older attest-build-provenance and attest-sbom actions (attest-build-provenance is now a thin wrapper on top of it). The signature is made through Sigstore using the job's OIDC (OpenID Connect) token, which is why id-token: write and attestations: write are needed. The consuming side verifies the image before running it like this:

gh attestation verify oci://ghcr.io/octo-org/bookstore-api:1.4.0 -R octo-org/bookstore

Attestations are available on all plans for public repositories and on GitHub Enterprise Cloud for private and internal repositories. Since it needs OIDC, it cannot run under act; I kept it out of the local main runs by running the step only on tag pushes, and validated it with actionlint. Tying SBOM (Software Bill of Materials) and signature verification to policy is the topic of the upcoming DevSecOps series.

The Server Side: the deploy User and the Compose File

You prepare the server once, by hand or with a configuration management tool. For local testing I used a container based on ubuntu:24.04 with OpenSSH, the Docker CLI (Command Line Interface) and the Compose plugin installed; it uses the host's Docker socket. Three things are needed. First, a non-root deploy user that can only log in with a key (PasswordAuthentication no, PermitRootLogin no). Second, in that user's authorized_keys, only the public half of an ed25519 key generated for CI and used nowhere else. Third, /srv/bookstore/compose.yaml:

name: ghawork2
services:
  api:
    image: ${BOOKSTORE_IMAGE:?BOOKSTORE_IMAGE is not set}
    restart: unless-stopped
    ports:
      - "18320:8080"

The project name and port are chosen so they don't collide with other containers on this machine; on a real server they would be bookstore and a port behind a reverse proxy. The image line is deliberately a variable: Compose reads it from the .env file in the same folder, and the :? syntax makes Compose refuse to start when the variable is empty. Every time, the deploy script copies the current .env to .env.previous. The state on the server after two deploys:

$ cat .env .env.previous
BOOKSTORE_IMAGE=localhost:18310/octo-org/bookstore-api@sha256:0909bf90a142ce3f4d3f7b00ba74feeebef0018a5f17d5ffee610ae5cd820394
BOOKSTORE_IMAGE=localhost:18310/octo-org/bookstore-api@sha256:65e0822b607b1e09eb3daee0f548edd267994cbb68127b2057cb3d567db64dbf

A rollback is nothing more than copying .env.previous back and running docker compose up -d; the image is already on the server. If the image on GHCR is private, the server has to be able to pull it too: either you run docker login ghcr.io once on the server with a token that has read:packages, or during the deploy you pass the job's GITHUB_TOKEN over SSH to docker login --password-stdin and run docker logout when done. The second leaves no long-lived credential but carries the token to the server; since the local registry has no auth, I did not run this step.

Deploying over SSH and Pinning known_hosts

The deploy job uses two secrets, both belonging to the staging environment: DEPLOY_SSH_KEY (the private key) and DEPLOY_KNOWN_HOSTS (the server's host key). The second is the most frequently skipped piece. You produce known_hosts by getting the server's host key once over a trusted channel and putting it into the secret:

$ ssh-keyscan -p 18322 -t ed25519 localhost
[localhost]:18322 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHtLXtTMvNYsUEfXrDwbC/wjziUo3vtxzI3Th3dq36vM
$ ssh-keygen -lf known_hosts
256 SHA256:dpL85UcfPA0APZ1fHWhjBhfySkXUHE8J41lw8VqZQDk [localhost]:18322 (ED25519)

Running ssh-keyscan inside the workflow is not pinning; you trust the key of whoever happens to be on the other end at that moment. The right way is to compare the fingerprint with the server's console (ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub) or the hosting panel and write it to the secret once.

The deploy job's output under act:

[deploy-vm/deploy-staging]   ✅  Success - Main Configure SSH [64.639708ms]
[deploy-vm/deploy-staging]   |  api Pulling
[deploy-vm/deploy-staging]   |  api Pulled
[deploy-vm/deploy-staging]   |  Container ghawork2-api-1  Creating
...
[deploy-vm/deploy-staging]   |  Container ghawork2-api-1  Started
[deploy-vm/deploy-staging]   |  Container ghawork2-api-1  Waiting
[deploy-vm/deploy-staging]   |  Container ghawork2-api-1  Healthy
[deploy-vm/deploy-staging]   | ghawork2-api-1 localhost:18310/octo-org/bookstore-api@sha256:65e0822b607b1e09eb3daee0f548edd267994cbb68127b2057cb3d567db64dbf Up Less than a second
[deploy-vm/deploy-staging]   ✅  Success - Main Pull and restart on the server [3.61241025s]
[deploy-vm/deploy-staging]   ✅  Success - Main Health check [130.912416ms]

We send the script over stdin with bash -s -- "$IMAGE_REF"; that way there is no deploy script to maintain on the server, and the image reference arrives as $1 without fighting quoting rules. Quoting the heredoc as 'EOF' matters: unquoted, $1 would be expanded on the runner. BatchMode=yes stops SSH from prompting for a password and leaving the job hanging for minutes if the key is rejected. docker compose up --wait waits until the container is up.

Now the first question. I regenerated the server's host keys (which is what a reinstall does) and tried to connect with the same known_hosts:

@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@    WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED!     @
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
IT IS POSSIBLE THAT SOMEONE IS DOING SOMETHING NASTY!
The fingerprint for the ED25519 key sent by the remote host is
SHA256:bjojcd8GqkHoYXvmfPcTMZRyV1TDHd40Hwe0RdfFO4M.
Host key for [localhost]:18322 has changed and you have requested strict checking.
Host key verification failed.

The deploy fails, and it should. When I tried the same with -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null, SSH printed a one-line warning (Permanently added '[localhost]:18322' (ED25519) to the list of known hosts.) and connected. With that option, the workflow blindly hands the production image reference, and more if agent forwarding is on, to any machine that hijacks the DNS (Domain Name System) record or the traffic. If the host key really did change, the job is to verify the new fingerprint and update the secret.

Alternative: Release Folders and systemd

On servers without containers the same idea works with release folders: copy the dotnet publish output with rsync to /srv/bookstore/releases/<sha>, switch the current symlink to the new folder with ln -sfn and run sudo systemctl restart bookstore. In sudoers, allow the deploy user only this single command. My test container has no systemd, so I did not run this variant; the systemd unit file is in the systemd post of the upcoming Web Servers and Hosting series. Deploying to Kubernetes is a different model altogether: there the workflow's job is to push the image and update the digest in a manifest; applying it to the cluster will be covered by the Kubernetes and GitOps series.

In Production

Generate the CI key only for CI and narrow its scope. In authorized_keys you can prefix the key with restrict,from="..." options; since GitHub-hosted runner IP ranges are broad and changing, a from restriction only makes sense with a self-hosted runner or a larger runner with a static IP.

If you don't want to open the server to SSH, invert the model. A self-hosted runner on the server, or the server itself watching the registry (pull-based deploy), removes the need for inbound connections. We discuss runner options in post 14.

Run the health check from where users come from, not from inside the server. In our example we go from the runner to $APP_URL/health; if the reverse proxy or TLS (Transport Layer Security) setup is broken, a health check inside the container would not see it.

Instead of rewriting the same job for production with a second environment, use the reusable workflow from post 12; staging and production drift apart most in copy-pasted deploy jobs.

Watch the disk. Every deploy pulls a new image; if you don't run docker image prune as a scheduled job, the server fills up after a few months. Keeping the last few images is enough for rollbacks.

Common Pitfalls

StrictHostKeyChecking=no or ssh-keyscan inside the workflow. Both mean "connect to whoever is there".

SSH as root. Deploying needs no root; a deploy user with the Docker group (or a one-command sudoers entry) is enough. Remember that the Docker group is practically equivalent to root, which is why protecting the key itself is critical.

Deploying with the latest tag. If two servers pull at different moments they run different versions, and there is no such thing as "the previous latest" for a rollback. Use the digest, or at least the SHA tag.

Writing the private key with echo. In some shells echo interprets backslashes and mangles line endings; printf '%s\n' is safe. If the trailing newline of the key gets lost, OpenSSH may fail to load the key.

Not setting a cache scope. If the API and web images share the same gha scope in a monorepo, every build overwrites the other's cache and the "why does the cache never hit" question begins.

Writing the heredoc unquoted. With <<EOF, $1 and $(...) are expanded on the runner; commands you meant to run on the server run on the runner.

A Small Experiment

★☆☆ Add a rollback job triggered by workflow_dispatch: it should connect to the server over SSH, copy .env.previous to .env and run docker compose up -d --wait.

★★☆ Use the command="..." option on the CI key in authorized_keys so it can run only a single deploy script, and have the script read the image reference from SSH_ORIGINAL_COMMAND. List what can no longer be done even if the key is stolen.

★★★ Add a second build step for the web image (scope=bookstore-web) and deploy both images by digest in the same Compose file. Then run the two builds with the same scope and count from the CACHED lines in the log how the cache hit rate drops.

Across these two posts we wrote nearly identical jobs for staging and production over and over, and the build steps were copied into every workflow. In the next post we reduce that duplication with composite actions, reusable workflows and YAML (YAML Ain't Markup Language) anchors.