VPS Runbook
01 — Architecture

VPS Self-Hosting Setup

A layered approach to running Docker Compose apps on a single Hetzner VPS. Traefik handles routing and TLS, a PLG stack covers observability, and each app is an isolated Compose project with its own backup sidecar.

Terraform
Hetzner Cloud
hcloud provider
Provision
Traefik v3
Reverse proxy
TLS · routing
Core
Monitoring
Prometheus · Loki
Grafana · Promtail
Core
Apps
Bugsink · Kaneo
Karakeep · …
Workloads

Server

cpx22 · 2 vCPU · 4 GB RAM Ubuntu · Docker CE UFW + Hetzner firewall fail2ban · SSH hardened cloud-init provisioned Hetzner backups enabled

Docker Networks

# Shared external networks
traefik-public   # Traefik discovers containers on this network
monitoring       # Prometheus, Loki, Promtail, cAdvisor

# Per-app (internal only)
default          # DB ↔ app service; never touches traefik-public

Backup pattern

Each app runs offen/docker-volume-backup on a staggered cron. PostgreSQL gets a pg_dump pre-hook before the volume snapshot. All backups ship to Backblaze B2. Errors trigger notifications via Shoutrrr / ntfy.

02 — Audit

Setup Review

Strengths
Server hardening

Root SSH disabled, password auth off, AllowUsers restriction, fail2ban, UFW + Hetzner firewall in parallel, secured shared memory.

Traefik configuration

exposedByDefault: false, no-new-privileges: true, Cloudflare trusted IPs in forwardedHeaders — correctly scoped and rarely done right.

Consistent logging

YAML anchors (x-logging, x-common-labels) reused faithfully across every Compose file. Promtail picks up all containers automatically via the labels.

Backup strategy

Per-app sidecars with staggered cron schedules. Pre-backup pg_dump hooks. B2 offsite storage. stop-during-backup labels prevent partial volume snapshots.

Health checks everywhere

depends_on: condition: service_healthy on all app/db pairs. Every service in the monitoring stack also has a health check defined.

Security headers

HSTS, XSS filter, content-type nosniff applied as named Traefik middlewares per app. Consistent across all deployed apps.

Developer ergonomics

Per-app justfile with up, down, logs, backup, update, generate-keys targets. Low operational friction.

Issues
Password in Docker label

The archive-pre label embeds PGPASSWORD=$POSTGRES_PASSWORD. After Compose processes it, the real value sits in the container label — visible via docker inspect.

Fix: mount a .pgpass file into the db container instead.
Docker socket not read-only

cadvisor and promtail mount /var/run/docker.sock without :ro. Both only read. Traefik and backup already do this correctly.

Fix: append :ro to those two volume mounts.
prevent_destroy variable unused

The variable is declared in variables.tf but the lifecycle block in main.tf hardcodes prevent_destroy = false, ignoring it entirely.

Fix: use prevent_destroy = var.prevent_terraform_destroy.
Log retention too tight

max-size: "1m" / max-file: "1" means 1 MB of logs per container before rotation. If Promtail is slow, logs vanish quickly.

Fix: raise to max-size: "10m" / max-file: "3".
Shared credentials duplicated

B2 keys and notification URL are copied into every app's .env. Rotating a B2 key means updating N files.

Fix: a shared core/secrets.env sourced via env_file.
No root-level .gitignore

core/traefik/ contains a live .env with no gitignore. Safe now, but one git init in the parent would expose it.

Fix: add a root .gitignore covering .env and acme/.
03 — Automation

Git Push Deployments

Three options that complement each other — they cover different deployment scenarios and can all run simultaneously.

Option A
Webhook

Your git host sends a signed POST on push. The server verifies the HMAC signature and runs docker compose pull && docker compose up -d. Zero polling; event-driven.

Best for: config and compose changes — updated docker-compose.yml, new env var, Traefik rule tweak.

Option B
Bare repo + post-receive

A bare git repo on the server acts as a remote. git push prod main triggers a post-receive hook that checks out code and runs the app. The classic Dokku model.

Best for: custom source code you own and build — client apps where you push code, not just configuration.

Option C
Watchtower

A container that polls your image registry and restarts services when a new tag is published. No webhook setup, no git remote — works through the registry alone.

Best for: upstream self-hosted apps (Bugsink, Kaneo, Karakeep) where deploying means pulling a new upstream image.

Recommended combination

Watchtower for upstream apps — new image published upstream, Watchtower pulls and restarts automatically. Webhook for your VPS config repo — push a compose change, server applies it. Option B only enters the picture if you add a client app with custom source to build and deploy.

04 — Integration

Webhook + Traefik

webhook runs as a systemd service on the host — not in a container. It binds to the Docker bridge gateway interface (172.17.0.1), which is reachable from inside Traefik's container but completely invisible to the public internet.

Request flow

GitHub / GitLab
hooks.domain.com:443
HTTPS · TLS terminated
Traefik
file provider route
172.17.0.1:9000
bridge gateway
webhook (systemd)
host process
deploy.sh

Why this beats a container for webhook

Running webhook on the host means deploy scripts run as your user with full access to app directories. No Docker socket exposure inside a container. No volume-mount gymnastics to reach the compose files. systemd keeps it alive and restarts it on failure.

Firewall layers

Hetzner firewall
Datacenter edge — drops all traffic not on ports 22 / 80 / 443 before it reaches the server NIC. Unaffected by Docker or anything running on the host.
Drops :9000
UFW (iptables)
Kernel-level secondary filter. Caveat: Docker bypasses UFW when you publish ports — it punches rules directly into iptables. The Hetzner firewall is unaffected, which is why both layers matter.
Drops :9000
Docker bridge (172.17.0.1)
Internal bridge interface. Traffic here never leaves the machine, so both firewalls are bypassed entirely. Traefik (inside a container) can reach the host on this interface.
webhook reachable
Docker + UFW gotcha

Publishing a port with ports: - "9000:9000" in Compose silently bypasses UFW. The Hetzner firewall would still block it, but relying on that alone is fragile. The right answer: don't publish port 9000 at all — bind to 172.17.0.1 and let Traefik be the only entry point.

Systemd unit

# /etc/systemd/system/webhook.service
[Unit]
Description=Webhook deployment server
After=network.target

[Service]
User=youruser
ExecStart=/usr/local/bin/webhook \
  -hooks /etc/webhook/hooks.json \
  -ip 172.17.0.1 \
  -port 9000
Restart=on-failure
RestartSec=5

[Install]
WantedBy=multi-user.targetsystemd

Traefik file provider route

# core/traefik/config/conf.d/webhook.yml
http:
  routers:
    webhook:
      rule: "Host(`hooks.yourdomain.com`)"
      entrypoints: websecure
      tls:
        certResolver: letsencrypt
      service: webhook
  services:
    webhook:
      loadBalancer:
        servers:
          - url: "http://172.17.0.1:9000"yaml

Enable the file provider in traefik.yml alongside the existing Docker provider:

providers:
  docker:
    endpoint: "unix:///var/run/docker.sock"
    exposedByDefault: false
    network: traefik-public
  file:                    # add this block
    directory: /etc/traefik/conf.d
    watch: trueyaml

Mount the new directory in Traefik's Compose service:

volumes:
  - ./config/traefik.yml:/etc/traefik/traefik.yml:ro
  - ./config/conf.d:/etc/traefik/conf.d:ro   # add
  - ./acme:/acmeyaml

Hook definition

[
  {
    "id": "deploy-myapp",
    "execute-command": "/apps/myapp/deploy.sh",
    "command-working-directory": "/apps/myapp",
    "trigger-rule": {
      "match": {
        "type": "payload-hmac-sha256",
        "secret": "your-webhook-secret",
        "parameter": {
          "source": "header",
          "name": "X-Hub-Signature-256"
        }
      }
    }
  }
]json

Deploy script

#!/bin/bash
set -euo pipefail
cd /apps/myapp
docker compose pull
docker compose up -d --remove-orphansbash
05 — Security

Secrets Management

A realistic look at the options. The right choice depends on what problem you're actually solving — and for a single-operator setup, that's often different from what the tooling is marketed toward.

Shared secrets.env File

A single gitignored file at core/secrets.env, referenced via env_file in every Compose stack. Rotate a B2 key once; all apps pick it up on next deploy.

Pros
  • Zero new tooling
  • Eliminates N-file rotation
  • Trivial to understand
Cons
  • Still plaintext on disk
  • Manual sync if multi-server
SOPS Encrypt-at-rest

Encrypts values (not keys) using an age keypair. The encrypted file is safe to commit. Decrypt at deploy time with sops --decrypt. Keys remain visible in diffs.

Pros
  • Secrets safe in git history
  • Readable diffs
  • No external service
Cons
  • Solves a problem you don't have — .env is already gitignored
  • Age private key still on disk
  • Adds a step to every deploy
Doppler SaaS

Central secrets manager. doppler run -- docker compose up -d injects secrets at runtime. No files on disk. Instant rotation across all apps from one UI.

Pros
  • No .env files on disk
  • Instant rotation
  • Full audit trail
  • Free tier is generous
Cons
  • Secrets live on Doppler's servers
  • Internet dependency at deploy time
  • SaaS risk (outage, discontinuation)
Infisical Open source / self-host

Same ergonomics as Doppler but open source. Can be self-hosted, though running it on the same VPS it protects creates a circular dependency if the server goes down.

Pros
  • Open source
  • Can be fully self-hosted
  • Same workflow as Doppler
Cons
  • Same-server circular dependency
  • Needs a separate server to be meaningful

Honest threat model

What does each approach actually protect against?

Threat .env files (gitignored) Doppler / Infisical
Server compromised (shell access) Exposed Still exposed
Secret accidentally committed to git Exposed Protected
Physical disk stolen Exposed Protected
Rotate one credential, update all apps Manual — N files Instant
Instant access revocation Manual file edit + redeploy Instant
Audit trail (who accessed what, when) None Full log
Verdict

For a well-hardened single-operator server, gitignored .env files are a reasonable tradeoff. The gains from Doppler or Infisical are operational — rotation, revocation, audit — not a meaningful security improvement against the most realistic threat (a compromised server). Start with a shared core/secrets.env to eliminate credential duplication. Reach for Doppler when you add a second server or a second operator.