How this site is hosted
Everything that runs behind divyam.top lives on one small virtual server — two vCPUs, four gigabytes of memory — and is described by a single private git repository. The server pulls that repository every five minutes and converges to it with Docker Compose. Nothing on the machine is edited by hand; to change production, I push.
This post is the written version of the architecture page that used to live at /infra/: what runs where, how a request gets in, where the ports are, how a deploy happens, and the two bugs that taught me the most while building it.
The shape of it
laptop GitHub (private repos, ssh)
────── ──────────────────────────
edit ─ git push ──────────────────────▶ voyager (this repo: how things run)
make deploy = push + ssh "deploy.sh" golu (app source, listed in apps.txt)
│
│ git pull · one read-only deploy key per repo
▼
┌─ VPS · Ubuntu 24.04 · 2 vCPU / 4 GB ───────────────────────────────────────────────┐
│ │
│ systemd voyager-deploy.timer ── every 5 min ──▶ scripts/deploy.sh │
│ pull voyager + apps/* ─▶ anything changed? ─▶ docker compose up -d --build│
│ + caddy reload │
│ docker compose project "voyager" (one private network) │
│ │
│ internet ──▶ :80 :443 ──▶ ┌───────────┐ portfolio (site/) · blog (state/blog) │
│ (tcp + udp for h3) │ caddy │ TLS · redirects · headers · proxies │
│ └───────────┘ │
│ ┌───────────┐ ┌───────────┐ ┌───────────┐ ┌───────────┐ │
│ │ golu │──▶│ stt │ │ gatus │◀──│statusjson │ │
│ │ telegram │ │ whisper │ │ status │ │ curl view │ │
│ └───────────┘ └───────────┘ └───────────┘ └───────────┘ │
│ no ports expose 9000 expose 8080 expose 8081 │
│ │
│ ufw 22/80/443 · fail2ban · sshd keys only · unattended-upgrades │
└─────────────────────────────────────────────────────────────────────────────────────┘
Three moving parts:
| part | what it is | where it lives |
|---|---|---|
| edge | Caddy — the only container with published ports; TLS, redirects, security headers, the static portfolio and this blog, reverse proxies to apps | docker-compose.yml (caddy), caddy/Caddyfile, site/, blog/ |
| apps | everything else, built from source checkouts under apps/<name>; they talk to each other over the compose network and never to the internet directly | apps.txt, the other compose services |
| reconciler | a systemd timer running scripts/deploy.sh, which pulls and converges | systemd/, scripts/deploy.sh |
A request’s journey
- DNS. Plain
Arecords at the registrar point the apex,www,statusandsecurityat the server. - Host firewall.
ufwallows22/tcp,80/tcpand443(TCP and UDP, for HTTP/3). Worth knowing: Docker programs its own iptables chains ahead of ufw’s, so anyports:mapping in Compose is reachable from the internet whether the firewall likes it or not. That is the whole reason only Caddy is allowed to have one. - Docker port mapping. Host
:80and:443are DNAT’ed into thecaddycontainer. - Caddy. It terminates TLS with a Let’s Encrypt certificate it obtained — and renews — on its own;
http://answers with a308tohttps://;wwwanswers with a301to the apex; HTTP/3 rides on443/udp. The apex block compresses responses, answers/healthzwith an empty200for uptime monitors, sets the usual security headers (HSTS with preload, a content-security policy, frame, referrer and permissions policies) and serves the portfolio withfile_serverfrom a bind mount of the repository’ssite/directory. A pushed change is therefore live as soon as the server has pulled — no container restart involved. This blog is served the same way from a directory the deploy writes. - Apps. Anything web-facing gets either a
handle /path/* { reverse_proxy <service>:<port> }block or its own site block, like the status page. Service names resolve through Docker’s internal DNS on the compose network.
Where the ports are
All of the port mapping lives in docker-compose.yml, and all of it is on the caddy service:
| host (public) | container | used for |
|---|---|---|
:80/tcp | caddy:80 | ACME HTTP-01 challenge, http → https redirect |
:443/tcp | caddy:443 | HTTPS (HTTP/1.1, HTTP/2) |
:443/udp | caddy:443 | HTTP/3 (QUIC) |
Everything else stays inside the compose network:
| service | reachability | note |
|---|---|---|
caddy admin :2019 | container-local only | caddy reload talks to it after each deploy |
stt :9000 | expose: only — http://stt:9000/asr from golu | Whisper speech-to-text for voice notes |
gatus :8080 | expose: only — Caddy proxies it as status.divyam.top | the public status page |
statusjson :8081 | expose: only — Caddy routes CLI user agents, Accept: application/json and /json there | curl https://status.divyam.top/ returns a compact JSON summary |
golu | publishes nothing | long-polls Telegram outbound; its health listener is reachable only from the compose network |
The rule of thumb I hold myself to: ports: means public, expose: means private. A new web-facing app gets expose: plus a Caddy route — never its own ports:.
How a deploy happens
git push ──▶ GitHub ──▶ (≤ 5 min) voyager-deploy.timer ──▶ deploy.sh
▲
make deploy ──────────┘ (push, then trigger deploy.sh over ssh)
scripts/deploy.sh, every run, under a lock:
- Fast-forward the repository. If
deploy.shitself changed, re-exec the new version so the same run uses it. - Install or refresh the systemd units from
systemd/if they differ. - For each line in
apps.txt(name url branch): clone intoapps/<name>or fast-forward it. URLs use ssh aliases, each backed by a read-only deploy key — one per repository, because GitHub refuses the same key on two repositories. The first time a new app appears, its key is generated and printed; the app is skipped until the key has been added on GitHub. - Make sure bind-mounted state directories are owned by the app’s uid (the bot runs as a non-root user in a distroless image).
- Compute a fingerprint: the repository’s
HEAD, every app’sHEAD, the server-local.envand the contents ofsecrets/. Unchanged → log “up to date” and exit. Otherwisedocker compose pull --ignore-buildable,docker compose up -d --build --remove-orphans, build this blog, reload Caddy, prune images and write a smallbuild-info.jsonthat the portfolio footer reads (“deployed from<sha>· n minutes ago”).
Local edits on the server are never discarded: if a checkout cannot fast-forward, the deploy logs a warning and leaves that checkout alone until a person looks at it.
What runs on it
- The portfolio — a static page, served straight from the repository.
- This blog — Hugo source in the repository, rendered at deploy time into a directory Caddy serves under
/blog/. - A status page — Gatus, configured from a YAML file in the repository, checking the edge (site,
/healthz, both redirects, DNS, certificate expiry) and the bot’s health endpoint from inside the network. A tiny Go service in front of it answerscurl https://status.divyam.top/with a compact JSON document while browsers still get the dashboard. - A Telegram bot — a small Go service a household actually uses every day. It long-polls Telegram outbound and needs no inbound port at all; a Whisper container beside it transcribes voice notes.
- CrowdSec — a log-driven intrusion prevention layer: it reads Caddy’s access log, shares signals with the community, and the edge itself is the bouncer (Caddy is built with the CrowdSec module, so a banned address gets
403before anything else runs). A small read-only page atsecurity.divyam.topshows what it is blocking right now. - NetBird — a self-hosted zero-trust mesh (management, signal, relay and an embedded identity provider in one container, plus the dashboard) behind the same edge at
vpn.divyam.top, so my own devices and the server share a private network without any extra port on the box.
What lives only on the server
Everything in git can be rebuilt from git. These cannot:
| path / volume | what |
|---|---|
.env | compose profiles and timezone — hand-written once |
secrets/ | bot token, API keys — never in git |
apps/ | source checkouts — recloned if missing |
state/ | bind mounts: the bot’s forwarded photos, nightly database snapshots, the rendered blog |
golu-data | the bot’s live SQLite database — its memory, snapshotted nightly |
caddy-data | the Let’s Encrypt account and certificates |
whisper-cache | the Whisper model, re-downloaded if lost |
gatus-data | status history |
If the server were wiped tomorrow: bootstrap a fresh VPS with the script in the repository, add the deploy key on GitHub, copy .env and secrets/ over, and the timer rebuilds the rest from git.
Two gotchas worth telling
Only what changed should restart. It didn’t, at first: the bot was recreated on every deploy even when its code had not moved. Docker 29’s containerd image store folds a freshly generated build attestation into the image ID on every build, so Compose saw a “new image” although every layer was identical. Building with BUILDX_NO_DEFAULT_ATTESTATIONS=1 makes the ID change only when the layers do. There is a longer write-up coming.
Caddy is reloaded, not restarted — its configuration arrives through a bind mount that Compose cannot see changing. And it is the directory caddy/ that is mounted, not the file: git replaces files by rename, and a single-file bind mount keeps pointing at the old inode forever. That one cost me an hour and a blank /healthz.
The stack
Ubuntu 24.04 · Docker 29 + Compose · Caddy 2 · systemd timers · Go · Whisper · Gatus · Hugo · CrowdSec · NetBird · Let’s Encrypt · GitHub deploy keys · ufw and fail2ban · SQLite.
Small on purpose. The repository is private, but I am happy to walk through it.