Deploying

Deploy a Docker Compose app to Hetzner with GitHub Actions

A Hetzner Cloud server set up by a tested cloud-init file, behind Hetzner's firewall, updated by a GitHub Actions workflow that builds your image, pushes it to GHCR and deploys it over SSH.

TL;DR

  • Create a Hetzner (opens in a new tab) Firewall that allows only TCP 22, 80 and 443 (and UDP 443), then a server with Ubuntu 24.04, your SSH key and a cloud-init file that installs Docker (opens in a new tab) and a deploy user.
  • Put compose.yaml, the Caddyfile, a .env file and a small deploy.sh in /srv/myapp, and log the server in to GitHub (opens in a new tab) Container Registry with a read-only token.
  • A GitHub Actions (opens in a new tab) workflow builds the image, pushes it to GHCR tagged with the commit SHA, and runs deploy.sh over SSH with a key and a pinned host key from encrypted secrets.
  • Rolling back is running deploy.sh with the previous SHA; a single app container means a few seconds of downtime per deploy, which two replicas remove.
On this page

To deploy a Docker Compose app to Hetzner, create a Hetzner Cloud server with Ubuntu 24.04 and a cloud-init file that installs Docker, attach a Hetzner Firewall that allows only SSH, HTTP and HTTPS, and put your compose.yaml on the server. Then let a GitHub Actions workflow build your image, push it to GitHub Container Registry, and run a small deploy script on the server over SSH. Every merge to main then reaches production by itself.

This article is the CI/CD and Hetzner part of our guide to deploying a web app to a VPS. The app in the examples is a Next.js (opens in a new tab) app built as in Deploy Next.js to a VPS with Docker and HTTPS, but any image with a health route works the same way.

What was tested, and how. On 11 November 2026 we checked the cloud-init file with cloud-init schema (cloud-init 26.2) and ran its commands in an Ubuntu 24.04 container, which installed Docker 29.9.3 and Compose 5.7. The workflow passes actionlint 1.7.12 and the deploy script passes ShellCheck 0.11.0. We ran the deploy script against a local registry, including a broken release and a rollback. We did not create a Hetzner server or run the workflow on GitHub: those steps follow Hetzner's and GitHub's documentation.

How the deploy to Hetzner works

git push → GitHub Actions: build image → push ghcr.io/you/app:<sha>
                         → ssh deploy@server "/srv/myapp/deploy.sh <sha>"
Hetzner server: deploy.sh → docker compose pull → docker compose up -d --wait
Visitors → Hetzner Firewall (22, 80, 443) → Caddy (HTTPS) → app → PostgreSQL

1. Make a deploy key for GitHub Actions

Use a separate key for the workflow, so you can revoke it without touching your own:

ssh-keygen -t ed25519 -f ~/.ssh/hetzner-deploy -N "" -C github-actions-deploy

This writes hetzner-deploy (the private key, which goes into a GitHub secret) and hetzner-deploy.pub (the public key, which goes into the cloud-init file). The key has no passphrase because nobody is there to type one; the secret store protects it instead.

2. Create a Hetzner Firewall

In Hetzner Console, open Firewalls, create one, and add these inbound rules:

ProtocolPortSourceWhy
TCP22Any (or your own IPs)SSH, for you and for GitHub Actions
TCP80AnyHTTP, for certificate checks and the redirect to HTTPS
TCP443AnyHTTPS
UDP443AnyHTTP/3 (optional)

According to Hetzner's documentation, once a firewall is applied, inbound traffic that no rule allows is blocked, outbound traffic is allowed unless you add outbound rules, and Cloud Firewalls cost nothing.

On a Docker host this firewall matters more than usual: Docker's documentation warns that ports published by containers bypass ufw and firewalld rules on the server, while Hetzner's firewall filters traffic before it reaches the server, so Docker cannot open a hole in it.

Restricting port 22 to your own addresses does not fit GitHub's hosted runners: GitHub publishes their address ranges but, because there are so many, recommends against using them as an allowlist. Keep SSH open to keys only, or deploy through a self-hosted runner (or a larger runner with a static IP range), as GitHub suggests, if you need SSH closed to the world.

3. Create the server with cloud-init

In Hetzner Console, create a server. The fields, in Hetzner's order:

  • Type. For a small app with PostgreSQL (opens in a new tab), 2 GB of memory is the floor and 4 GB comfortable. On 11 November 2026 Hetzner listed the CPX12 (1 vCPU, 2 GB, 40 GB) at €9.99 and the CPX22 (2 vCPU, 4 GB, 80 GB) at €16.99 a month in Helsinki, excluding VAT and including the IPv4 address. In Nuremberg the same plans cost €11.99 and €19.99. The cheaper Cost-Optimized plans (CX and CAX) were marked not available that day. You can resize later.
  • Location. Nuremberg, Falkenstein or Helsinki in the EU (20 TB of traffic included), Ashburn or Hillsboro in the US, or Singapore. The US and Singapore plans include much less traffic, and not every plan is offered everywhere: that day the CPX22 was listed in Nuremberg, Helsinki and Singapore.
  • Image. Ubuntu 24.04.
  • Networking. IPv4 and IPv6. Hetzner charges €0.50 a month for a primary IPv4 address and nothing for IPv6.
  • SSH key. Your own public key, for logging in as root. Hetzner notes that you cannot add a key from the console after the server exists.
  • Firewalls. The one from step 2.
  • Backups. Optional: 20% of the server's price for daily copies with seven slots.
  • Cloud config. Paste the file below, with your deploy key's public half.
#cloud-config
package_update: true
package_upgrade: true
packages:
  - ca-certificates
  - curl
users:
  - name: deploy
    shell: /bin/bash
    ssh_authorized_keys:
      - ssh-ed25519 AAAA...your-hetzner-deploy.pub-contents... github-actions-deploy
runcmd:
  - install -m 0755 -d /etc/apt/keyrings
  - curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
  - chmod a+r /etc/apt/keyrings/docker.asc
  - |
    . /etc/os-release
    printf '%s\n' "Types: deb" "URIs: https://download.docker.com/linux/ubuntu" \
      "Suites: $VERSION_CODENAME" "Components: stable" \
      "Architectures: $(dpkg --print-architecture)" \
      "Signed-By: /etc/apt/keyrings/docker.asc" > /etc/apt/sources.list.d/docker.sources
  - apt-get update
  - apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
  - usermod -aG docker deploy
  - install -d -o deploy -g deploy /srv/myapp

The runcmd steps are Docker's own installation instructions for Ubuntu (the apt repository method), written so cloud-init can run them unattended. They also create a deploy user that only the deploy key can log in as (from your computer too: pass -i ~/.ssh/hetzner-deploy), add it to the docker group, and create /srv/myapp for the app. One warning from Docker's documentation: the docker group grants root-level privileges, so treat the deploy key like a root key.

When the server is up, check that cloud-init has finished: ssh root@SERVER_IP cloud-init status --wait should end with status: done, and ssh -i ~/.ssh/hetzner-deploy deploy@SERVER_IP docker compose version should print the Compose version.

4. Pin the server's host key

So the workflow talks to nothing but your server, read the host key from your computer:

ssh-keyscan -t ed25519 SERVER_IP > hetzner_known_hosts
ssh-keygen -lf hetzner_known_hosts

Then compare that fingerprint with the one the server reports about itself, from a session you trust (for example the provider's web console): ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub. If they match, the contents of hetzner_known_hosts become the SSH_KNOWN_HOSTS secret.

5. Put the app's files on the server

In /srv/myapp the server needs four files. compose.yaml and the Caddyfile are the ones from the Next.js tutorial: the app's image is ghcr.io/your-name/myapp:${APP_TAG:-latest}, PostgreSQL publishes no port, and Caddy (opens in a new tab) publishes 80 and 443.

.env holds the database password and the running release. It must contain an APP_TAG= line, which the deploy script rewrites:

POSTGRES_PASSWORD=replace-with-the-output-of-openssl-rand-hex-24
APP_TAG=latest

deploy.sh is the whole deploy:

#!/bin/sh
# Usage: deploy.sh <image tag>. Pulls that image and restarts the app with it.
set -eu
cd "$(dirname "$0")"
sed -i "s/^APP_TAG=.*/APP_TAG=$1/" .env
docker compose pull app
docker compose up -d --wait --wait-timeout 120
docker image prune -af

It records the release in .env (so a reboot or a manual docker compose up -d keeps it), pulls the image and lets Compose replace the app container. --wait waits until the containers are running and healthy and fails if one is not, which fails the workflow run. The last line deletes every image no container uses, earlier releases included, so they do not fill the disk; they stay in the registry, and a rollback pulls them again. Without -a, docker image prune removes only untagged images, and every release here has a tag.

Copy them over and lock down .env:

scp -i ~/.ssh/hetzner-deploy compose.yaml Caddyfile .env deploy.sh deploy@SERVER_IP:/srv/myapp/
ssh -i ~/.ssh/hetzner-deploy deploy@SERVER_IP 'chmod 600 /srv/myapp/.env && chmod +x /srv/myapp/deploy.sh'

Packages you push to GitHub Container Registry are private when first published, so the server must log in to pull them, and outside of Actions the registry accepts only personal access tokens (classic). Create one with only the read:packages scope and log in once as the deploy user:

ssh -i ~/.ssh/hetzner-deploy deploy@SERVER_IP
echo "$CR_PAT" | docker login ghcr.io -u YOUR_GITHUB_USERNAME --password-stdin

Docker keeps the credential in the deploy user's ~/.docker/config.json. If your image holds nothing private, making the package public removes this step.

6. Add the secrets and the workflow

In the repository's Settings → Environments, create an environment named production and add three secrets to it:

  • SSH_PRIVATE_KEY: the contents of ~/.ssh/hetzner-deploy;
  • SSH_KNOWN_HOSTS: the contents of hetzner_known_hosts;
  • SERVER_HOST: the server's IP address, the same one you scanned for the host key (ssh looks the host key up under the name you connect to).

Environment secrets are available only to jobs that reference the environment, and the environment's deployment branch rule (in the same settings page) can limit it to main. GitHub does not pass secrets to workflows triggered by pull requests from forks.

One plan limit: GitHub's documentation says environment secrets and deployment branch rules work in private repositories only on GitHub Pro, Team or Enterprise; on GitHub Free they need a public repository. In a private repository on GitHub Free, add the three values as repository secrets instead and delete the environment: production line. Any workflow in the repository can then read them, including one changed in a pull request from a branch of the repository, so give push access only to people you would trust with the server.

Then commit .github/workflows/deploy.yml:

name: Deploy

on:
  push:
    branches: [main]
  workflow_dispatch:

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

env:
  IMAGE: ghcr.io/your-name/myapp # lowercase: GHCR rejects capital letters

jobs:
  build:
    runs-on: ubuntu-24.04
    permissions:
      contents: read
      packages: write
    steps:
      - uses: actions/checkout@v7
      - uses: docker/setup-buildx-action@v4
      - uses: docker/login-action@v4
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - uses: docker/build-push-action@v7
        with:
          context: .
          push: true
          tags: |
            ${{ env.IMAGE }}:${{ github.sha }}
            ${{ env.IMAGE }}:latest
          cache-from: type=gha
          cache-to: type=gha,mode=max

  deploy:
    needs: build
    runs-on: ubuntu-24.04
    environment: production
    permissions: {}
    steps:
      - name: Set up the SSH key and the server's host key
        env:
          SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
          SSH_KNOWN_HOSTS: ${{ secrets.SSH_KNOWN_HOSTS }}
        run: |
          install -m 700 -d ~/.ssh
          printf '%s\n' "$SSH_PRIVATE_KEY" > ~/.ssh/deploy_key
          chmod 600 ~/.ssh/deploy_key
          printf '%s\n' "$SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts
      - name: Deploy this commit's image
        env:
          SERVER_HOST: ${{ secrets.SERVER_HOST }}
        run: ssh -i ~/.ssh/deploy_key "deploy@$SERVER_HOST" "/srv/myapp/deploy.sh $GITHUB_SHA"

What each part does:

  • concurrency queues deploys instead of running two at once, and cancel-in-progress: false never stops a deploy halfway.
  • permissions gives the build job only what it needs (read the code, write packages) and the deploy job none.
  • docker/login-action logs in to ghcr.io with the workflow's own GITHUB_TOKEN, which GitHub's documentation recommends for publishing packages from a workflow; the package is linked to the repository automatically.
  • docker/build-push-action builds with BuildKit and pushes two tags: the commit SHA, which identifies the release forever, and latest. cache-from/cache-to: type=gha keeps layers in GitHub's Actions cache, so unchanged dependency layers are not rebuilt.
  • The SSH step writes the key and the pinned host key from environment variables rather than ${{ }} expressions inside the script, as GitHub's security guidance recommends: the value never becomes part of the generated script, so it cannot change what the script does.

The pinned versions (checkout@v7, setup-buildx-action@v4, login-action@v4, build-push-action@v7) were the latest major releases on 11 November 2026. GitHub's security guidance recommends pinning each action to a full-length commit SHA from the action's own repository.

7. Point DNS and run the first deploy

Create an A record for your domain with the server's IPv4 address and an AAAA record with its IPv6 address, and the same for www. Wait until dig +short example.com returns them, so Caddy's first certificate request succeeds.

Then push to main (or start the workflow from the Actions tab, thanks to workflow_dispatch). The build job pushes ghcr.io/your-name/myapp:<sha>, the deploy job runs the script, and the first docker compose up also starts PostgreSQL and Caddy, which requests the certificate.

When a release is broken, and rolling back

With one app container, a deploy replaces the running container before the new one proves itself. We tested a release whose app listened on the wrong port: docker compose up --wait reported container ... is unhealthy and exited with an error, which turns the workflow run red, and Caddy answered 502 until we rolled back.

Rolling back is the same script with the previous good SHA (it is in the workflow history and in the registry's tags):

ssh -i ~/.ssh/hetzner-deploy deploy@SERVER_IP /srv/myapp/deploy.sh 3f2a1c9d...previous-sha

In our test that brought the site back to 200 without rebuilding: the script pulls the earlier image from the registry if the server no longer has it.

If a few seconds of errors per deploy or per bad release are too many, run two app replicas behind Caddy and update them one at a time. Zero-downtime deployments with Docker Compose changes this setup to do that; a broken release there never takes traffic.

What this setup does not do yet

  • Back up the database. Turn on Hetzner's Backups, and add a nightly pg_dump copied off the server; the pillar guide shows the tested commands.
  • Rotate container logs. Set max-size for Docker's json-file driver, as the pillar guide shows.
  • Preview pull requests. That needs a second server and a few more pieces: see a preview of every pull request, on your own server.

Doing this with OpsCrew

OpsCrew is an open-source tool that sets this up for you on Hetzner Cloud. Its agents read the repository, propose server options with their monthly cost, then write the infrastructure code and show the plan with its SHA-256 hash; nothing is created until you approve that exact plan (how it works). The deploy workflow arrives as a pull request, runs only on pushes that change the app (when deploys run), and switches releases without downtime, switching back by itself when the new version fails its checks. opscrew rollback puts the previous image back by its digest (rollback). If you already have a server elsewhere, OpsCrew deploys to it the same way, after showing you every change it would make to it (a server you already have).

To choose between Hetzner and the other common option, read Hetzner vs DigitalOcean for a small production app, and for every other part of running the server, the complete guide to deploying an app to a VPS.

Key facts

FactValueSource
Hetzner CPX22 (2 vCPU AMD, 4 GB, 80 GB NVMe), Helsinki16.99 EUR per month, excl. VAT€19.99 in Nuremberg; 20 TB traffic included in EU locations; price includes the IPv4 address.Hetzner Cloud, Regular Performance (opens in a new tab)Verified on
Hetzner primary IPv4 address0.50 EUR per month, excl. VATPrimary IPv6 addresses are free.Hetzner Docs, Primary IPs (opens in a new tab)Verified on
Hetzner Backups20 % of the server's monthly priceDaily copies with seven backup slots per server.Hetzner Cloud billing FAQ (opens in a new tab)Verified on
Hetzner Cloud FirewallsFree; inbound traffic without a matching rule is blocked; up to 5 firewalls per serverHetzner Docs, Firewalls (opens in a new tab)Verified on
Tools validated for this articlecloud-init 26.2 (schema check), actionlint 1.7.12, ShellCheck 0.11.0, Docker 29.9.3, Compose 5.7Verified on

Frequently asked questions

Can I use ufw on a Hetzner server running Docker?

You can, but do not rely on it for container ports: Docker's documentation warns that ports published by containers bypass ufw and firewalld rules. Use Hetzner's Cloud Firewall, which filters traffic before it reaches the server, as the main firewall and publish only the reverse proxy's ports.

Do I need a container registry to deploy Docker Compose to Hetzner?

It is the cleanest way: the image is built once in CI and the server only pulls it. GitHub Container Registry works with the workflow's own token for pushing, so no extra credential is needed in CI. Building on the server instead works for small apps but needs the source code and more memory on the server.

Sources

  1. Creating a server (opens in a new tab), Hetzner, accessed on
  2. Firewalls overview (opens in a new tab), Hetzner, accessed on
  3. Primary IPs overview (opens in a new tab), Hetzner, accessed on
  4. Billing FAQ (opens in a new tab), Hetzner, accessed on
  5. Hetzner Cloud, Regular Performance plans (opens in a new tab), Hetzner, accessed on
  6. Hetzner Cloud, Cost-Optimized plans (opens in a new tab), Hetzner, accessed on
  7. Install Docker Engine on Ubuntu (opens in a new tab), Docker, accessed on
  8. Linux post-installation steps for Docker Engine (opens in a new tab), Docker, accessed on
  9. Working with the Container registry (opens in a new tab), GitHub, accessed on
  10. Using secrets in GitHub Actions (opens in a new tab), GitHub, accessed on
  11. Deployments and environments (opens in a new tab), GitHub, accessed on
  12. Secure use reference (opens in a new tab), GitHub, accessed on
  13. GitHub-hosted runners reference (opens in a new tab), GitHub, accessed on
  14. docker/build-push-action (opens in a new tab), Docker (GitHub repository), accessed on
  15. actionlint (opens in a new tab), rhysd (GitHub repository), accessed on
  • Deploying

    How to deploy a web app to a VPS: the complete guide

    From an empty server to a production app: server size, Docker, a reverse proxy with HTTPS, DNS, CI/CD, secrets, backups, zero-downtime updates, previews, logs and hardening.

    12 min read

  • Deploying

    Deploy Next.js to a VPS with Docker and HTTPS

    A tested setup for Next.js 16 on your own server: standalone output, a small multi-stage Docker image, PostgreSQL and Caddy with automatic HTTPS, and the build-time traps that break apps after the first deploy.

    8 min read

  • Deploying

    Deploy a Docker app to a DigitalOcean Droplet

    Create a Droplet, lock it down with a Cloud Firewall, install Docker from Docker's own repository, and run your app with Compose and automatic HTTPS. Every command tested; prices checked on 11 November 2026.

    8 min read

Put OpsCrew to work on your app.

Run npx opscrew init in your app's repository. It checks your tools and tokens first, for free.