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.
hcloud provider
TLS · routing
Grafana · Promtail
Karakeep · …
Server
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.
Setup Review
Root SSH disabled, password auth off, AllowUsers restriction, fail2ban, UFW + Hetzner firewall in parallel, secured shared memory.
exposedByDefault: false, no-new-privileges: true, Cloudflare trusted IPs in forwardedHeaders — correctly scoped and rarely done right.
YAML anchors (x-logging, x-common-labels) reused faithfully across every Compose file. Promtail picks up all containers automatically via the labels.
Per-app sidecars with staggered cron schedules. Pre-backup pg_dump hooks. B2 offsite storage. stop-during-backup labels prevent partial volume snapshots.
depends_on: condition: service_healthy on all app/db pairs. Every service in the monitoring stack also has a health check defined.
HSTS, XSS filter, content-type nosniff applied as named Traefik middlewares per app. Consistent across all deployed apps.
Per-app justfile with up, down, logs, backup, update, generate-keys targets. Low operational friction.
The archive-pre label embeds PGPASSWORD=$POSTGRES_PASSWORD. After Compose processes it, the real value sits in the container label — visible via docker inspect.
.pgpass file into the db container instead.cadvisor and promtail mount /var/run/docker.sock without :ro. Both only read. Traefik and backup already do this correctly.
:ro to those two volume mounts.prevent_destroy variable unusedThe variable is declared in variables.tf but the lifecycle block in main.tf hardcodes prevent_destroy = false, ignoring it entirely.
prevent_destroy = var.prevent_terraform_destroy.max-size: "1m" / max-file: "1" means 1 MB of logs per container before rotation. If Promtail is slow, logs vanish quickly.
max-size: "10m" / max-file: "3".B2 keys and notification URL are copied into every app's .env. Rotating a B2 key means updating N files.
core/secrets.env sourced via env_file..gitignorecore/traefik/ contains a live .env with no gitignore. Safe now, but one git init in the parent would expose it.
.gitignore covering .env and acme/.Git Push Deployments
Three options that complement each other — they cover different deployment scenarios and can all run simultaneously.
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.
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
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
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
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.
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.
- Zero new tooling
- Eliminates N-file rotation
- Trivial to understand
- Still plaintext on disk
- Manual sync if multi-server
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.
- Secrets safe in git history
- Readable diffs
- No external service
- Solves a problem you don't have — .env is already gitignored
- Age private key still on disk
- Adds a step to every deploy
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.
- No .env files on disk
- Instant rotation
- Full audit trail
- Free tier is generous
- Secrets live on Doppler's servers
- Internet dependency at deploy time
- SaaS risk (outage, discontinuation)
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.
- Open source
- Can be fully self-hosted
- Same workflow as Doppler
- 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 |
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.