An all-in-one infrastructure repo that serves and deploys every app on a single VPS: a shared TLS reverse proxy (Caddy) + webhook-driven continuous deployment + operations tooling (encrypted backups, uptime monitoring, AI-assisted incident diagnosis). One small repo drives the whole server.
Built for a single-VPS box hosting several heterogeneous apps (Symfony/FrankenPHP, Astro, Node, WordPress, static sites) behind one proxy, with an identical deploy flow regardless of the stack.
Running in production on my own VPS — it's what serves Hush, Red Flag Bingo and other projects. Not a demo: this is the real deployment pipeline behind my apps.
Internet
│ 80/443 (auto TLS, Let's Encrypt)
▼
┌──────────────────────────────┐
│ Caddy (proxy_caddy) │ reverse proxy + HTTPS + security headers
│ *.example.dev, example.dev │
└───────┬───────────────┬───────┘
│ docker network │
│ « web » │
┌──────────┴───┐ ┌─────┴───────────────────────────┐
│ apps │ │ deploy.example.dev │
│ (containers) │ │ ▼ │
│ Symfony, │ │ webhook (proxy_webhook) │ ← CD listener
│ Astro, WP… │ │ ▼ dispatch.sh │
└───────────────┘ │ git pull + ./deploy.sh │
└─────────────────────────────────┘
Continuous deployment: git push main ──▶ GitHub webhook (HMAC) ──▶ dispatch.sh
──▶ git reset --hard origin/main ──▶ ./deploy.sh (build + up + healthcheck)
Three building blocks:
| Block | Role | Where |
|---|---|---|
| Reverse proxy | Auto TLS, *.domain routing, security headers |
Caddyfile, docker-compose.yml |
| Continuous deployment | push main → build + redeploy of the affected app |
deploy/ (README) |
| Operations | Encrypted backups + monitoring + AI diagnosis | ops/ (README) |
You push to main, and the app redeploys itself on the VPS — whatever its
tech stack. Each project ships a deploy.sh script (same contract everywhere:
build → optional migrations → up → blocking HTTP healthcheck); the central
webhook triggers it after aligning the code with origin/main.
Full details and the onboarding procedure for a new project:
deploy/README.md.
- TLS automated (Let's Encrypt) + uniform security headers (HSTS, nosniff,
anti-clickjacking…) via the
(security_headers)snippet in theCaddyfile. - Databases are never publicly exposed (private per-app
internalDocker networks). - Signed webhook: only a
POSTwith a valid HMAC-SHA256 signature (WEBHOOK_SECRET) andref == refs/heads/maintriggers a deploy. - Read-only deploy keys, one per repo: the webhook only ever
pulls, so it carries dedicated read-only GitHub deploy keys (indeploy/keys/, mounted at/keys). A compromised key grants only read access to a single repo — no write, no access to the others. ⚠️ Thewebhookcontainer mounts the Docker socket, so it effectively has root on the host (needed to drive the stacks). It's the most sensitive component — protected by the HMAC secret. Planned hardening: Docker socket-proxy, GitHub IP allowlist at the Caddy level.- Secrets out of git:
.env(HMAC secret, email) and the private keys are never committed (.gitignore). Production.envfiles arechmod 600.
# 1. Shared network (once)
docker network create web
# 2. Clone and configure
git clone git@github.com:<owner>/push-to-deploy.git ~/push-to-deploy
cd ~/push-to-deploy
cp .env.example .env # set: LETSENCRYPT_EMAIL, WEBHOOK_SECRET
# 3. Start the proxy + the deploy listener
docker compose up -d
# 4. (optional) install the operations tooling -> see ops/README.mdTo wire a project into auto-deploy: deploy/README.md.
push-to-deploy/
├── Caddyfile # routing + TLS + security headers (one block per site)
├── docker-compose.yml # caddy + webhook services, « web » network, volumes
├── .env(.example) # LETSENCRYPT_EMAIL, WEBHOOK_SECRET (out of git)
├── deploy/ # continuous deployment
│ ├── dispatch.sh # resolves repo→folder, git reset --hard, runs ./deploy.sh
│ ├── projects.conf # routing table owner/repo = /srv/<folder>
│ ├── hooks.json # listener rules (HMAC + ref==main)
│ ├── deploy.sh.template # reference deploy contract (copied per project)
│ ├── keys/ # read-only deploy keys, one per repo (out of git)
│ └── webhook/ # listener image (adnanh/webhook + docker CLI + git)
└── ops/ # operations (cron) — see ops/README.md
├── backup.sh # encrypted restic backup (databases + data + tooling)
├── uptime-check.sh # HTTP monitoring -> GitHub issue (AI diagnosis) on incident
├── deploy-watch.sh # deploy-failure diagnosis -> GitHub issue
└── lib.sh # shared helpers
| Component | Version | Source |
|---|---|---|
| Caddy | 2-alpine |
official image |
| adnanh/webhook | 2.8.2 |
deploy/webhook/Dockerfile |
| Docker Compose (listener plugin) | v2.29.7 |
same |
| Listener base image | docker:27-cli |
same |
Ops tools (restic / gh / claude) |
0.19.0 / 2.95.0 / 2.1.185 |
ops/README.md |
- The
Caddyfileis mounted as a single file in the container. Any change is deployed with./proxy-deploy.sh(validates the new file, then recreates the caddy container). Areload— ordocker compose up -dalone — isn't enough: editing creates a new inode the single-file bind-mount doesn't follow, so only a recreate (--force-recreate) re-binds the new file. projects.confis case-insensitive (Labault/Hush==Labault/hush).- The deploy runs
git reset --hard origin/main: any unpushed local change is overwritten. So each project'sdeploy.shmust be committed.
dispatch.sh is deliberately deterministic (rails). Judgment (failure
diagnosis, risk review, audit) is delegated to read-only agents that execute
nothing and open issues — see ops/README.md. Hardening
roadmap: Docker socket-proxy, remote backups (B2/S3), persisted resource limits,
IaC.
See LICENSE.