How this site is hosted

· 8 min read

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:

partwhat it iswhere it lives
edgeCaddy — the only container with published ports; TLS, redirects, security headers, the static portfolio and this blog, reverse proxies to appsdocker-compose.yml (caddy), caddy/Caddyfile, site/, blog/
appseverything else, built from source checkouts under apps/<name>; they talk to each other over the compose network and never to the internet directlyapps.txt, the other compose services
reconcilera systemd timer running scripts/deploy.sh, which pulls and convergessystemd/, scripts/deploy.sh

A request’s journey

  1. DNS. Plain A records at the registrar point the apex, www, status and security at the server.
  2. Host firewall. ufw allows 22/tcp, 80/tcp and 443 (TCP and UDP, for HTTP/3). Worth knowing: Docker programs its own iptables chains ahead of ufw’s, so any ports: 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.
  3. Docker port mapping. Host :80 and :443 are DNAT’ed into the caddy container.
  4. Caddy. It terminates TLS with a Let’s Encrypt certificate it obtained — and renews — on its own; http:// answers with a 308 to https://; www answers with a 301 to the apex; HTTP/3 rides on 443/udp. The apex block compresses responses, answers /healthz with an empty 200 for uptime monitors, sets the usual security headers (HSTS with preload, a content-security policy, frame, referrer and permissions policies) and serves the portfolio with file_server from a bind mount of the repository’s site/ 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.
  5. 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)containerused for
:80/tcpcaddy:80ACME HTTP-01 challenge, http → https redirect
:443/tcpcaddy:443HTTPS (HTTP/1.1, HTTP/2)
:443/udpcaddy:443HTTP/3 (QUIC)

Everything else stays inside the compose network:

servicereachabilitynote
caddy admin :2019container-local onlycaddy reload talks to it after each deploy
stt :9000expose: only — http://stt:9000/asr from goluWhisper speech-to-text for voice notes
gatus :8080expose: only — Caddy proxies it as status.divyam.topthe public status page
statusjson :8081expose: only — Caddy routes CLI user agents, Accept: application/json and /json therecurl https://status.divyam.top/ returns a compact JSON summary
golupublishes nothinglong-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:

  1. Fast-forward the repository. If deploy.sh itself changed, re-exec the new version so the same run uses it.
  2. Install or refresh the systemd units from systemd/ if they differ.
  3. For each line in apps.txt (name url branch): clone into apps/<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.
  4. 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).
  5. Compute a fingerprint: the repository’s HEAD, every app’s HEAD, the server-local .env and the contents of secrets/. Unchanged → log “up to date” and exit. Otherwise docker compose pull --ignore-buildable, docker compose up -d --build --remove-orphans, build this blog, reload Caddy, prune images and write a small build-info.json that 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 answers curl 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 403 before anything else runs). A small read-only page at security.divyam.top shows 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 / volumewhat
.envcompose 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-datathe bot’s live SQLite database — its memory, snapshotted nightly
caddy-datathe Let’s Encrypt account and certificates
whisper-cachethe Whisper model, re-downloaded if lost
gatus-datastatus 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.