VPS multi-app onboarding (Docker Compose)¶
Small guide for running additional Docker Compose applications on the same
Hetzner + Tailscale host as
podcast_scraper prod, without provisioning new IaaC.
New apps use the same GitOps shape: CI → SSH over tailnet → compose pull && up.
Prerequisites¶
- Prod VPS already exists; you reach it as
deploy@prod-podcast.<tailnet>.ts.net(see Prod runbook). - You can add GitHub Actions secrets (
TS_AUTHKEY, SSH key or reuse deploy) and optional Tailscale ACL updates intailscale/policy.hujson.
1. Isolate each app on disk¶
Pick a dedicated root per app (example):
| App | Suggested path | Notes |
|---|---|---|
| podcast_scraper (existing) | /srv/podcast-scraper |
Do not share .env or data dirs with other apps. |
| Other app | /srv/<app-slug> |
Clone or rsync repo; keep compose/ and .env under this tree. |
Rules:
- One
.envper app, mode600, owned by the user that runsdocker compose. - Named volumes and bind mounts must not collide with podcast_scraper (
docker volume ls,docker compose ps).
2. Ports and Tailscale exposure¶
- Assign each stack distinct host ports (for example app A on
8081, app B on8082). - Do not open Hetzner public TCP 80/443 for hobby stacks; keep ingress tailnet-only (same security model as RFC-082).
Expose over Tailscale:
- Per-app
tailscale serve(separate local port per app), or - Extra MagicDNS names if you register additional hostnames (operational detail in Tailscale admin).
Document the stable URL you chose (https://<name>.<tailnet>.ts.net/) in the app repo README.
3. Compose invocation (match podcast_scraper lessons)¶
If the first -f file lives under compose/, Compose’s project dir may resolve to
.../compose/ — see the Prod runbook FAQ section Why --env-file?.
When running compose by hand on the VPS, prefer:
cd /srv/<app-slug>
docker compose --env-file /srv/<app-slug>/.env -f compose/docker-compose.yml up -d
Adjust -f list to your project. systemd EnvironmentFile= for that unit avoids the pitfall for boot-time up.
4. systemd (optional but recommended)¶
Add a separate unit per app, mirroring podcast-scraper.service:
WorkingDirectory=/srv/<app-slug>EnvironmentFile=/srv/<app-slug>/.envExecStart=/usr/bin/docker compose ... up -d --remove-orphansExecStop=/usr/bin/docker compose ... down
Enable after .env exists and a one-time docker compose pull has succeeded.
5. GitOps from the other repository¶
Copy the pattern from
deploy-prod.yml:
- Job joins tailnet:
tailscale/github-action@v2withsecrets.TS_AUTHKEY. - SSH as
deployto the same VPS FQDN. cd /srv/<app-slug> && git pull && docker compose ... pull && up -d.- Optional: health-check
curlagainst tailnet URL or in-container probe.
Keep deploy keys or deploy user access scoped; prefer read-only deploy where possible.
6. Tailscale ACL¶
Ensure the tag:gha-deployer (or your runner identity) can SSH to tag:prod (already required for podcast_scraper).
No change needed if you reuse the same SSH target and user.
7. Observability and cost¶
- RAM/CPU: extra stacks compete with podcast_scraper; watch
docker statsafter cutover. - Logs/metrics: either piggyback Grafana Agent patterns from this repo or accept “logs on host only” for small apps.
- Backups: define per-app backup (same idea as corpus snapshots — script + GHA + object store or Releases).
8. Rollback¶
Same idea as prod: pin image tags or prior git SHA on the host, then compose up -d.
Document PODCAST_IMAGE_TAG-style variable names per app.
References¶
- Prod runbook — bootstrap, health checks, constraints.
- Prod operator cheat sheet — quick commands.
- RFC-082 — hosting and GitOps decisions.
- RFC-083 — optional public TLS edge + multi-vhost design (operators stay on Tailscale).