Skip to content
Back to skills

Deploy To Hetzner

ASecurity

Use when putting a containerized service on Hetzner Cloud with OpenTofu — a VM per environment, Cloud Firewall, private network, hardened cloud-init, Docker Compose with Traefik TLS, deploys by image digest, backups with a restore drill.

  • 5 stars
  • 0 votes
  • 0 copies
  • 0 views
  • Added October 10, 2026
developmentrustbashnodedockerawsgitdatabasesecurity

Security analysis

A96/100
  • mediumUses curl or wget to download content

Pro scans all 3 files and shows the line behind each finding

Scanned October 10, 2026

npx -y skills add VelimirMueller/lab-claude-skills --skill deploy-to-hetzner --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Deploy To Hetzner?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Deploy To Hetzner
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/velimirmueller-deploy-to-hetzner/badge)](https://www.skillsdirectory.com/skills/velimirmueller-deploy-to-hetzner)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: deploy-to-hetzner
description: Use when putting a containerized service on Hetzner Cloud with OpenTofu — a VM per environment, Cloud Firewall, private network, hardened cloud-init, Docker Compose with Traefik TLS, deploys by image digest, backups with a restore drill.
---

# Deploy to Hetzner

One VM per environment, hardened by cloud-init, serving a Compose stack behind Traefik. The image, the compose files and the deploy script all ship through the same reviewed pipeline. Contract: [environments.md](../_shared/environments.md), [deploy-script.md](../_shared/deploy-script.md).

## 1. Audit current state (change nothing)

```bash
cat .claude/stack-profile.md 2>/dev/null        # hosting, iac, database.host, ci
ls infra/modules/hetzner-host infra/modules/host-bootstrap deploy/ compose*.y*ml 2>/dev/null
ls deploy/stack deploy/host deploy/envs 2>/dev/null
hcloud context list 2>/dev/null                 # context names only, never token values
[[ -n "${HCLOUD_TOKEN:-}" ]] && echo "HCLOUD_TOKEN set" || echo "HCLOUD_TOKEN unset"
tofu version 2>&1 | head -1
```

Read from the profile: `hosting` (must list `hetzner`), `iac` (opentofu), `database.host`, `ci`. If there is no profile, detect from the `infra/` and `deploy/` trees above. Never ask what the profile already answers.

## 2. Decide

- Profile says `hosting` is `vercel` or `ionos` (and not `hetzner`): stop. Point to [deploy-to-vercel](../deploy-to-vercel/SKILL.md) or [deploy-to-ionos](../deploy-to-ionos/SKILL.md).
- No image yet (`containerize-service` has not produced a digest): run [containerize-service](../containerize-service/SKILL.md) first; this skill deploys a digest, not a build.
- No `infra/modules/hetzner-host` and no `deploy/`: full (steps 3 to 7).
- Modules exist but a piece is missing (no `deploy/stack/bin/backup.sh`, no `deploy/envs`): delta, add only the missing piece.
- Modules, deploy layout, GitHub environments, DNS and backups all present and verified: "already in place", run step 7, stop.

## 3. Detect the track

- **Single VM Compose (default):** one VM per environment, one Compose project on it. Use this unless a trigger below is met.
- **Graduate to k3s:** do not build k3s here. The triggers (more than one VM per environment behind a load balancer, rolling zero-downtime needs, several teams, node-level failure tolerance) and the k3s target (`v1.37.1+k3s1`) are in [hetzner-patterns.md](./hetzner-patterns.md#rule-graduate-to-k3s-only-on-these-triggers). This skill stops at the Compose host.

## 4. Install only what is missing

```bash
brew install hcloud        # 1.70.1; or mise use hcloud@1.70.1
```

OpenTofu 1.13.1 through [set-up-opentofu](../set-up-opentofu/SKILL.md) — run it first, or in the same change. It writes the `infra/` roots, encrypted remote state, `TF_ENCRYPTION`, the CI gate and drift detection. This skill adds the Hetzner modules on top.

## 5. Generate the seams

1. **Modules** `infra/modules/hetzner-host` and `infra/modules/host-bootstrap`, plus one environment root (`infra/envs/<env>/`) that instantiates the module. Every file, verbatim, in [hetzner-patterns.md](./hetzner-patterns.md). The module holds the `environment` validation, the firewall, the private network, the primary IP and the volume.
2. **`deploy/` layout** exactly as [deploy-script.md](../_shared/deploy-script.md) shows: `deploy/deploy.sh`, `deploy/host/run.sh`, `deploy/stack/bin/backup.sh`, `deploy/stack/bin/restore-drill.sh`, `deploy/stack/compose.yaml`, `deploy/stack/traefik/`, `deploy/envs/<env>/config.env`. Link `deploy.sh` and `run.sh` to [deploy-script.md](../_shared/deploy-script.md); do not restate their bodies. The stack files are in [compose-patterns.md](./compose-patterns.md).
3. **`deploy/stack/compose.yaml`** and the two Traefik files, verbatim from [compose-patterns.md](./compose-patterns.md).
4. **Backups:** `deploy/stack/bin/backup.sh` and `deploy/stack/bin/restore-drill.sh`, verbatim from [compose-patterns.md](./compose-patterns.md). The systemd timer comes from cloud-init.
5. **Host config** `deploy.conf` and the deploy key's forced command: written by cloud-init, so nothing to hand-edit on the host.

## 6. Wire

- **GitHub environments** (`dev`, `stg`, `prd`). Secrets: `DEPLOY_SSH_KEY`, `HCLOUD_TOKEN`, state credentials (`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`) and `TF_ENCRYPTION` — the last three per [set-up-opentofu](../set-up-opentofu/SKILL.md). Variables: `DEPLOY_HOST`, `DEPLOY_HOST_KEY`, `APP_URL`, `HEALTH_URL`. `DEPLOY_HOST_KEY` is the pinned host key; no trust on first use.
- **No OIDC for Hetzner** (none found 2026-10-09): `DEPLOY_SSH_KEY` and `HCLOUD_TOKEN` are long-lived. Scope the key to one environment's forced command and the token to one project; rotate both on a schedule. Say this in the README.
- **DNS:** an A record and an AAAA record to the primary IP (`tofu output ipv4` / `ipv6`). The IP outlives the server, so DNS survives a rebuild.
- **Host secrets** (Postgres password, any app secret): the integration point is the optional hook `HOOK_PRE_UP` in `/etc/deploy/deploy.conf` (add `HOOK_PRE_UP=/opt/deploy/hooks/pre-up.sh`), which `run.sh` calls before `compose up`. The hook is written per [manage-secrets](../manage-secrets/SKILL.md) (sops+age, one age key per host) and must create `/opt/app/secrets/postgres_password` (mode 0400, on disk: the file is bind-mounted, so it has to survive the unattended 04:30 reboot). Plaintext is never in the repo and never in the bundle. The hook script is in [manage-secrets](../manage-secrets/SKILL.md) (run locally against sops + age; not yet on a live host).
- **Logs and traces:** [deploy-otel-collector](../deploy-otel-collector/SKILL.md) adds the collector service to the same Compose project.
- **Approval:** `prd` deploy and apply behind the environment's required reviewers ([environments.md](../_shared/environments.md)); `dev` on merge to `main`.

## 7. Verify

```bash
cd infra/envs/dev
tofu fmt -check -recursive ../.. ; tofu init -lockfile=readonly ; tofu validate   # valid; init succeeded
tofu plan -input=false                                                            # exit 0/2, the planned diff
tofu apply -input=false                                                           # creates server, firewall, network, IP, volume
ssh admin@<IP> 'cloud-init status --wait'                                          # "status: done"
ssh -o BatchMode=yes deploy@<IP> 'id'                                             # refused: forced command only accepts "<env> <image>"
deploy/deploy.sh dev ghcr.io/acme/shop@sha256:<digest>                             # "deployed dev ghcr.io/acme/shop@sha256:…"
curl -fsS https://<host>/healthz                                                    # "ok"
ssh admin@<IP> 'docker compose --project-directory /opt/app -f /opt/app/compose.yaml ps'   # every service: healthy
ssh admin@<IP> 'sudo bash /opt/app/bin/backup.sh'                                  # exit 0; restic snapshots lists one
ssh admin@<IP> 'sudo bash /opt/app/bin/restore-drill.sh'                           # "restore drill ok" (needs PG_IMAGE; see compose-patterns.md)
```

What was exercised during research, and what was not, is split in [hetzner-patterns.md](./hetzner-patterns.md#unverified-on-a-live-host): the compose stack, the deploy script, the backup and restore drill, `tofu validate`/`test` of the modules and the cloud-init YAML parse were run locally; no real Hetzner project existed, so server creation, firewall effect, volume-mount timing, cloud-init on a live host and ACME issuance were not.

## References

- [hetzner-patterns.md](./hetzner-patterns.md): the module files (verbatim) and the rules that justify each resource.
- [compose-patterns.md](./compose-patterns.md): the stack, Traefik and backup files (verbatim) and the rules.
- [../_shared/deploy-script.md](../_shared/deploy-script.md): the deploy/run script contract this host honours.
- [../_shared/environments.md](../_shared/environments.md), [../_shared/hosting-decision.md](../_shared/hosting-decision.md), [../_shared/stack-versions.md](../_shared/stack-versions.md).
- [../set-up-opentofu/SKILL.md](../set-up-opentofu/SKILL.md), [../manage-secrets/SKILL.md](../manage-secrets/SKILL.md), [../deploy-otel-collector/SKILL.md](../deploy-otel-collector/SKILL.md), [../set-up-delivery-pipeline/SKILL.md](../set-up-delivery-pipeline/SKILL.md).
- [../../core/_shared/security-baseline.md](../../core/_shared/security-baseline.md), [../../core/_shared/engineering-principles.md](../../core/_shared/engineering-principles.md).

Files in this skill

  • SKILL.md8.4 KB
  • compose-patterns.md14.3 KB
  • hetzner-patterns.md19.6 KB

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.