{"authors":[{"name":"Divyam Azad","url":"https://divyam.top/"}],"description":"Notes on running small, honest infrastructure: GitOps, Kubernetes, security, and the bugs in between.","feed_url":"https://divyam.top/blog/feed.json","home_page_url":"https://divyam.top/blog/","icon":"https://divyam.top/blog/og-default.png","items":[{"authors":[{"name":"Divyam Azad","url":"https://divyam.top/"}],"content_html":"\u003cp\u003eTwice a day I need one of two things: a URL short enough to say out loud or paste into a chat, or a place to drop a log, a stack trace or the output of \u003ccode\u003ekubectl get pods\u003c/code\u003e so someone can look at it without me attaching a file. Every free service that does this either tracks the clicks, expires in ways I don\u0026rsquo;t control, or goes away. The box behind this site already has Caddy, CrowdSec and a deploy loop; adding one more tiny service was cheaper than another account. The result is \u003ca href=\"https://github.com/nihaldivyam/hop\"\u003ehop\u003c/a\u003e, MIT-licensed, running at \u003ccode\u003ego.divyam.top\u003c/code\u003e and \u003ccode\u003epaste.divyam.top\u003c/code\u003e.\u003c/p\u003e\n\u003ch2 id=\"what-it-is\"\u003eWhat it is\u003c/h2\u003e\n\u003cp\u003eOne Go binary, standard library plus a pure-Go SQLite driver, ~30 MB as a distroless image. It routes by \u003ccode\u003eHost\u003c/code\u003e header: on the links host \u003ccode\u003eGET /\u0026lt;slug\u0026gt;\u003c/code\u003e answers \u003ccode\u003e302\u003c/code\u003e to the stored URL and counts the hit; on the pastes host \u003ccode\u003eGET /\u0026lt;id\u0026gt;\u003c/code\u003e returns the text as \u003ccode\u003etext/plain\u003c/code\u003e, while a browser (or \u003ccode\u003e?html=1\u003c/code\u003e, or \u003ccode\u003e/\u0026lt;id\u0026gt;.sh\u003c/code\u003e to pick a language) gets a dark, numbered view. \u003ccode\u003e/\u0026lt;id\u0026gt;/raw\u003c/code\u003e always returns the bytes untouched.\u003c/p\u003e\n\u003cp\u003eReads are anonymous. Both can be created anonymously within strict limits (random slugs plus a confirmation page for links; size and a 24 h expiry for pastes; rate limits on both) — the token lifts the limits. Deleting anything, custom slugs, plain redirects and the full paste limits still require the bearer token that lives only on the server. That split is the security model: the public side can submit only small, short-lived text at a throttled rate, and the per-IP token bucket plus CrowdSec in front take care of the rest.\u003c/p\u003e\n\u003cp\u003eA few decisions that earned their keep:\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003eExpiry is a first-class field.\u003c/strong\u003e Pastes default to 30 days (\u003ccode\u003e0\u003c/code\u003e means forever); links can carry a \u003ccode\u003ettl\u003c/code\u003e too. A janitor goroutine deletes expired rows every ten minutes, so the database never becomes an archive of things I meant to throw away.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003ePastes are never HTML unless you ask.\u003c/strong\u003e The raw and default responses are \u003ccode\u003etext/plain\u003c/code\u003e; the pretty view is a rendered template with everything escaped and a strict \u003ccode\u003eContent-Security-Policy\u003c/code\u003e (\u003ccode\u003edefault-src 'none'\u003c/code\u003e). A paste containing \u003ccode\u003e\u0026lt;script\u0026gt;\u003c/code\u003e is just text.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eReserved names.\u003c/strong\u003e \u003ccode\u003eapi\u003c/code\u003e, \u003ccode\u003eraw\u003c/code\u003e, \u003ccode\u003estatic\u003c/code\u003e, \u003ccode\u003ehealthz\u003c/code\u003e, \u003ccode\u003eadmin\u003c/code\u003e and friends cannot be slugs, so nothing you create can shadow the service\u0026rsquo;s own routes.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eBoring operations.\u003c/strong\u003e It is an entry in the GitOps repo\u0026rsquo;s \u003ccode\u003eapps.txt\u003c/code\u003e, built on the box by the deploy script, reverse-proxied by Caddy with the same security headers as the rest, and its write token was minted once by the deploy script into a secrets file. Push to \u003ccode\u003emain\u003c/code\u003e, wait for the next tick, done.\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch2 id=\"how-i-actually-use-it\"\u003eHow I actually use it\u003c/h2\u003e\n\u003cp\u003eFour ways, depending on where my hands are.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003eIn a browser.\u003c/strong\u003e The landing pages at \u003ccode\u003ego.divyam.top\u003c/code\u003e and \u003ccode\u003epaste.divyam.top\u003c/code\u003e have a small UI: unlock once with the token (kept in the browser\u0026rsquo;s local storage, never sent anywhere but the service itself), then a form — URL plus optional slug and expiry for links; a text box with title, language and expiry for pastes. This is the path I point people to when they ask \u0026ldquo;how do I use it\u0026rdquo;.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003eFrom the infrastructure repo\u003c/strong\u003e, where I already am most of the day:\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#e6edf3;background-color:#0d1117;-moz-tab-size:2;-o-tab-size:2;tab-size:2;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-bash\" data-lang=\"bash\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003emake short \u003cspan style=\"color:#79c0ff\"\u003eURL\u003c/span\u003e\u003cspan style=\"color:#ff7b72;font-weight:bold\"\u003e=\u003c/span\u003ehttps://kubernetes.io/docs/ \u003cspan style=\"color:#79c0ff\"\u003eSLUG\u003c/span\u003e\u003cspan style=\"color:#ff7b72;font-weight:bold\"\u003e=\u003c/span\u003ek8s    \u003cspan style=\"color:#8b949e;font-style:italic\"\u003e# -\u0026gt; https://go.divyam.top/k8s\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003emake short \u003cspan style=\"color:#79c0ff\"\u003eURL\u003c/span\u003e\u003cspan style=\"color:#ff7b72;font-weight:bold\"\u003e=\u003c/span\u003ehttps://some.long/url                   \u003cspan style=\"color:#8b949e;font-style:italic\"\u003e# random 5-character slug\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003emake paste \u003cspan style=\"color:#79c0ff\"\u003eFILE\u003c/span\u003e\u003cspan style=\"color:#ff7b72;font-weight:bold\"\u003e=\u003c/span\u003enotes.md                               \u003cspan style=\"color:#8b949e;font-style:italic\"\u003e# -\u0026gt; https://paste.divyam.top/8hZp2Kq3\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003emake hop-token                                         \u003cspan style=\"color:#8b949e;font-style:italic\"\u003e# prints the write token (it lives on the box)\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e\u003cstrong\u003eWith the CLI\u003c/strong\u003e, which is the same binary in a different mode. One-time setup is \u003ccode\u003emake hop-setup\u003c/code\u003e (or \u003ccode\u003ego install github.com/nihaldivyam/hop@latest\u003c/code\u003e followed by \u003ccode\u003ehop login --api https://go.divyam.top --token …\u003c/code\u003e), and then:\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#e6edf3;background-color:#0d1117;-moz-tab-size:2;-o-tab-size:2;tab-size:2;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-bash\" data-lang=\"bash\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003ehop link https://kubernetes.io/docs/ k8s     \u003cspan style=\"color:#8b949e;font-style:italic\"\u003e# custom slug\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003ehop link https://example.com                 \u003cspan style=\"color:#8b949e;font-style:italic\"\u003e# random slug\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003ekubectl get pods -A | hop paste              \u003cspan style=\"color:#8b949e;font-style:italic\"\u003e# anything from stdin\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003ehop paste deploy.sh --ttl 1d                 \u003cspan style=\"color:#8b949e;font-style:italic\"\u003e# title and language from the file name\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003ehop ls                                       \u003cspan style=\"color:#8b949e;font-style:italic\"\u003e# what exists\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003ehop rm k8s                                   \u003cspan style=\"color:#8b949e;font-style:italic\"\u003e# delete a link (or a paste by id)\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e\u003cstrong\u003eWith curl\u003c/strong\u003e, or anything that speaks HTTP — useful from CI or a script on another machine:\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#e6edf3;background-color:#0d1117;-moz-tab-size:2;-o-tab-size:2;tab-size:2;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-bash\" data-lang=\"bash\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003ecurl -H \u003cspan style=\"color:#a5d6ff\"\u003e\u0026#34;Authorization: Bearer \u003c/span\u003e\u003cspan style=\"color:#79c0ff\"\u003e$TOKEN\u003c/span\u003e\u003cspan style=\"color:#a5d6ff\"\u003e\u0026#34;\u003c/span\u003e -H \u003cspan style=\"color:#a5d6ff\"\u003e\u0026#34;Content-Type: application/json\u0026#34;\u003c/span\u003e \u003cspan style=\"color:#79c0ff\"\u003e\\\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  -d \u003cspan style=\"color:#a5d6ff\"\u003e\u0026#39;{\u0026#34;url\u0026#34;:\u0026#34;https://kubernetes.io/docs/\u0026#34;,\u0026#34;slug\u0026#34;:\u0026#34;k8s\u0026#34;,\u0026#34;ttl\u0026#34;:\u0026#34;720h\u0026#34;}\u0026#39;\u003c/span\u003e \u003cspan style=\"color:#79c0ff\"\u003e\\\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  https://go.divyam.top/api/links\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003ecurl -H \u003cspan style=\"color:#a5d6ff\"\u003e\u0026#34;Authorization: Bearer \u003c/span\u003e\u003cspan style=\"color:#79c0ff\"\u003e$TOKEN\u003c/span\u003e\u003cspan style=\"color:#a5d6ff\"\u003e\u0026#34;\u003c/span\u003e -H \u003cspan style=\"color:#a5d6ff\"\u003e\u0026#34;X-Title: notes.md\u0026#34;\u003c/span\u003e -H \u003cspan style=\"color:#a5d6ff\"\u003e\u0026#34;X-TTL: 7d\u0026#34;\u003c/span\u003e \u003cspan style=\"color:#79c0ff\"\u003e\\\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  --data-binary @notes.md https://paste.divyam.top/api/pastes\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eBoth answer with JSON (\u003ccode\u003eshort_url\u003c/code\u003e / \u003ccode\u003eurl\u003c/code\u003e, plus \u003ccode\u003eexpires_at\u003c/code\u003e); \u003ccode\u003eGET\u003c/code\u003e on the same endpoints lists, \u003ccode\u003eDELETE /api/links/\u0026lt;slug\u0026gt;\u003c/code\u003e and \u003ccode\u003eDELETE /api/pastes/\u0026lt;id\u0026gt;\u003c/code\u003e remove. Reading never needs the token: \u003ccode\u003ecurl https://paste.divyam.top/\u0026lt;id\u0026gt;\u003c/code\u003e prints the text, and \u003ccode\u003ego.divyam.top/\u0026lt;slug\u0026gt;\u003c/code\u003e is a plain redirect that any browser follows.\u003c/p\u003e\n\u003ch2 id=\"limits-deliberately\"\u003eLimits, deliberately\u003c/h2\u003e\n\u003cp\u003ePastes are capped at 256 KiB — this is for logs and snippets, not file sharing. Slugs are \u003ccode\u003e[A-Za-z0-9_-]\u003c/code\u003e, up to 64 characters. Anonymous reads are rate-limited per IP (using the proxy\u0026rsquo;s forwarded address, so the limit is per visitor and not per Caddy). There is one token, because there is one user; if that ever changes it grows a users table, not a login page.\u003c/p\u003e\n\u003ch2 id=\"whats-next\"\u003eWhat\u0026rsquo;s next\u003c/h2\u003e\n\u003cp\u003eSyntax highlighting in the browser view is the obvious gap, and a hit counter per link on the landing page would be nice to have. Neither needs a new service, which is rather the point: the box gets one more useful thing without getting one more thing to operate.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003eUpdate:\u003c/strong\u003e you can also name your own paste URL — open \u003ccode\u003epaste.divyam.top/\u0026lt;name\u0026gt;\u003c/code\u003e (up to 15\ncharacters) and, if nobody has used it, write the paste right there; \u003ccode\u003ehop paste file --id \u0026lt;name\u0026gt;\u003c/code\u003e\nand \u003ccode\u003eX-Id\u003c/code\u003e on the API do the same.\u003c/p\u003e\n","date_modified":"2026-08-22T19:40:00+05:30","date_published":"2026-08-22T19:40:00+05:30","id":"https://divyam.top/blog/hop-short-links-and-pastes/","summary":"I kept pasting logs into third-party pastebins and shortening URLs with services that track clicks. So I wrote a 30 MB Go binary that does both, behind the same edge as everything else on this box — and deploys itself from git.","title":"hop: short links and pastes in one small Go binary","url":"https://divyam.top/blog/hop-short-links-and-pastes/"},{"authors":[{"name":"Divyam Azad","url":"https://divyam.top/"}],"content_html":"\u003cp\u003eI wanted a private network between my devices and the server: the box reachable over the mesh instead of the public internet, admin interfaces that do not need to be public, and a control plane I own. \u003ca href=\"https://netbird.io/\"\u003eNetBird\u003c/a\u003e is WireGuard underneath, which is what I run elsewhere anyway, and its self-hosted server has become small enough to sit next to a portfolio site on a 4 GB VPS.\u003c/p\u003e\n\u003ch2 id=\"topology\"\u003eTopology\u003c/h2\u003e\n\u003cp\u003eAs of version 0.77 NetBird ships a \u003cstrong\u003ecombined server\u003c/strong\u003e: management, signal, the relay and an embedded identity provider (Dex with local users) in one binary. No Zitadel, no Postgres, no separate IdP container. With the dashboard that is two containers and roughly 250 MB of RAM.\u003c/p\u003e\n\u003cp\u003eThe parts that usually cost ports were the parts I wanted to avoid:\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003egRPC and the API\u003c/strong\u003e go through Caddy on 443. Caddy speaks h2c to the backend, so the management and signal gRPC services work without exposing anything.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eThe relay\u003c/strong\u003e is multiplexed onto the same port as a WebSocket stream (\u003ccode\u003erels://vpn.\u0026lt;domain\u0026gt;:443\u003c/code\u003e), again through Caddy.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eSTUN\u003c/strong\u003e is outsourced to public servers in the config, which disables the built-in STUN listener. Peers that cannot punch through NAT fall back to the relay over 443.\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003eNet effect: the box still publishes exactly two ports, 80 and 443, both on Caddy.\u003c/p\u003e\n\u003cp\u003eThe Caddy block is NetBird\u0026rsquo;s own example, verbatim apart from the hostnames:\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#e6edf3;background-color:#0d1117;-moz-tab-size:2;-o-tab-size:2;tab-size:2;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-caddyfile\" data-lang=\"caddyfile\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#79c0ff;font-weight:bold\"\u003evpn.divyam.top\u003c/span\u003e {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#ff7b72\"\u003eimport\u003c/span\u003e security_headers\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#d2a8ff;font-weight:bold\"\u003e@grpc\u003c/span\u003e \u003cspan style=\"color:#ff7b72\"\u003eheader\u003c/span\u003e \u003cspan style=\"color:#a5d6ff\"\u003eContent-Type\u003c/span\u003e \u003cspan style=\"color:#a5d6ff\"\u003eapplication/grpc*\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#ff7b72\"\u003ereverse_proxy\u003c/span\u003e \u003cspan style=\"color:#d2a8ff;font-weight:bold\"\u003e@grpc\u003c/span\u003e \u003cspan style=\"color:#a5d6ff\"\u003eh2c://netbird-server:80\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#d2a8ff;font-weight:bold\"\u003e@backend\u003c/span\u003e \u003cspan style=\"color:#ff7b72\"\u003epath\u003c/span\u003e \u003cspan style=\"color:#a5d6ff\"\u003e/relay*\u003c/span\u003e \u003cspan style=\"color:#a5d6ff\"\u003e/ws-proxy/*\u003c/span\u003e \u003cspan style=\"color:#a5d6ff\"\u003e/api/*\u003c/span\u003e \u003cspan style=\"color:#a5d6ff\"\u003e/oauth2/*\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#ff7b72\"\u003ereverse_proxy\u003c/span\u003e \u003cspan style=\"color:#d2a8ff;font-weight:bold\"\u003e@backend\u003c/span\u003e netbird-server:\u003cspan style=\"color:#a5d6ff\"\u003e80\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#ff7b72\"\u003ereverse_proxy\u003c/span\u003e \u003cspan style=\"color:#d2a8ff;font-weight:bold\"\u003e/*\u003c/span\u003e netbird-dashboard:\u003cspan style=\"color:#a5d6ff\"\u003e80\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e}\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003ch2 id=\"configuration-as-a-template\"\u003eConfiguration as a template\u003c/h2\u003e\n\u003cp\u003eThe server takes a single \u003ccode\u003econfig.yaml\u003c/code\u003e. Mine lives in the repository as a template with \u003ccode\u003e${VAR}\u003c/code\u003e placeholders; a small bootstrap script generates the secrets (relay auth secret, datastore encryption key, session cookie key, the initial owner\u0026rsquo;s email and password) into a server-local env file on first run, renders the template into a server-local directory, and — this matters later — restarts the server only when the rendered file actually changed. The rendered file is never committed. The container mounts the \u003cem\u003edirectory\u003c/em\u003e containing it, not the file, for the reasons in \u003ca href=\"/blog/the-bind-mount-that-pinned-an-inode/\"\u003ethe inode post\u003c/a\u003e.\u003c/p\u003e\n\u003cp\u003eBoth services sit behind a compose profile, so the repository can carry them while they are switched off; enabling is one line in the server\u0026rsquo;s \u003ccode\u003e.env\u003c/code\u003e and a deploy.\u003c/p\u003e\n\u003ch2 id=\"first-start\"\u003eFirst start\u003c/h2\u003e\n\u003cp\u003eThe first boot downloads two GeoLite databases before it listens, so for a minute Caddy answers \u003ccode\u003e502\u003c/code\u003e on the API paths while the dashboard (a static app) already loads. Gatus noticed before I did. After that: OIDC discovery at \u003ccode\u003e/oauth2/.well-known/openid-configuration\u003c/code\u003e, dashboard, \u003ccode\u003e401\u003c/code\u003e from \u003ccode\u003e/api/users\u003c/code\u003e without a token — all as expected.\u003c/p\u003e\n\u003cp\u003eThen I tried to log in.\u003c/p\u003e\n\u003ch2 id=\"the-gotcha\"\u003eThe gotcha\u003c/h2\u003e\n\u003cp\u003eThe upstream \u003ccode\u003econfig.yaml.example\u003c/code\u003e has this, commented out:\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#e6edf3;background-color:#0d1117;-moz-tab-size:2;-o-tab-size:2;tab-size:2;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-yaml\" data-lang=\"yaml\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#7ee787\"\u003eowner\u003c/span\u003e:\u003cspan style=\"color:#6e7681\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#6e7681\"\u003e  \u003c/span\u003e\u003cspan style=\"color:#7ee787\"\u003eemail\u003c/span\u003e:\u003cspan style=\"color:#6e7681\"\u003e \u003c/span\u003e\u003cspan style=\"color:#a5d6ff\"\u003e\u0026#34;\u0026lt;owner email\u0026gt;\u0026#34;\u003c/span\u003e\u003cspan style=\"color:#6e7681\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#6e7681\"\u003e  \u003c/span\u003e\u003cspan style=\"color:#7ee787\"\u003epassword\u003c/span\u003e:\u003cspan style=\"color:#6e7681\"\u003e \u003c/span\u003e\u003cspan style=\"color:#a5d6ff\"\u003e\u0026#34;initial-password\u0026#34;\u003c/span\u003e\u003cspan style=\"color:#6e7681\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eI rendered the generated password into that field, and every login failed. The server log had the answer:\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode\u003eERRO [err: parsing bcrypt hash: crypto/bcrypt: hashedSecret too short to be a bcrypted password]\n     idp/dex/logrus_handler.go:83: failed to login user\n\u003c/code\u003e\u003c/pre\u003e\u003cp\u003eThe field is passed straight through to the embedded Dex as a \u003cstrong\u003ebcrypt hash\u003c/strong\u003e — \u003ccode\u003ecombined/cmd/config.go\u003c/code\u003e assigns \u003ccode\u003eOwner.Password\u003c/code\u003e to the IdP\u0026rsquo;s \u003ccode\u003eHash\u003c/code\u003e, and the IdP\u0026rsquo;s own type documents it as \u0026ldquo;the bcrypt hash of the user\u0026rsquo;s password (required)\u0026rdquo;. The example is misleading; the code is unambiguous.\u003c/p\u003e\n\u003cp\u003eThe fix is to hash the password once when rendering the template. I used the \u003ccode\u003ecaddy hash-password\u003c/code\u003e command from the Caddy image already on the box (any bcrypt tool works), and I cached the hash next to the other secrets — bcrypt salts are random, so re-hashing on every run would change the rendered config and restart the server on every deploy for no reason. Restart the server once and the login works with the plaintext password you generated.\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#e6edf3;background-color:#0d1117;-moz-tab-size:2;-o-tab-size:2;tab-size:2;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-bash\" data-lang=\"bash\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#79c0ff\"\u003ehash\u003c/span\u003e\u003cspan style=\"color:#ff7b72;font-weight:bold\"\u003e=\u003c/span\u003e\u003cspan style=\"color:#ff7b72\"\u003e$(\u003c/span\u003eprintf \u003cspan style=\"color:#a5d6ff\"\u003e\u0026#39;%s\\n\u0026#39;\u003c/span\u003e \u003cspan style=\"color:#a5d6ff\"\u003e\u0026#34;\u003c/span\u003e\u003cspan style=\"color:#79c0ff\"\u003e$NETBIRD_ADMIN_PASSWORD\u003c/span\u003e\u003cspan style=\"color:#a5d6ff\"\u003e\u0026#34;\u003c/span\u003e | docker run --rm -i caddy:2-alpine caddy hash-password\u003cspan style=\"color:#ff7b72\"\u003e)\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#8b949e;font-style:italic\"\u003e# render: password: \u0026#34;$2a$14$...\u0026#34;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eTwo small things I would tell my past self: \u003ccode\u003ecaddy hash-password\u003c/code\u003e reads a \u003cem\u003eline\u003c/em\u003e from stdin, so the trailing newline matters; and under \u003ccode\u003eset -e\u003c/code\u003e a failed command substitution exits the script silently — log loudly around anything that can fail.\u003c/p\u003e\n\u003ch2 id=\"the-client-side\"\u003eThe client side\u003c/h2\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003eMac\u003c/strong\u003e: the NetBird app, \u003cem\u003eSettings → Advanced → Management URL\u003c/em\u003e set to the self-hosted address, then Connect; the browser opens the dashboard\u0026rsquo;s login.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003ePhone\u003c/strong\u003e: the NetBird app, \u003cem\u003eChange server\u003c/em\u003e on the first screen, same URL, same login.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eThe server itself\u003c/strong\u003e as a headless peer: create a setup key in the dashboard and run the Linux client with \u003ccode\u003e--management-url\u003c/code\u003e and \u003ccode\u003e--setup-key\u003c/code\u003e. From then on the box has a mesh address, and public SSH can go away.\u003c/li\u003e\n\u003cli\u003eLater: make the server an exit node (a \u003ccode\u003e0.0.0.0/0\u003c/code\u003e route with the box as the routing peer) so a phone on hotel Wi‑Fi rides through it.\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003eIt is a good feeling to have the control plane in the same repository as everything else — and a good reminder that \u0026ldquo;read the code, not the example\u0026rdquo; still applies to tools you like.\u003c/p\u003e\n","date_modified":"2026-08-22T09:00:00+05:30","date_published":"2026-08-22T09:00:00+05:30","id":"https://divyam.top/blog/self-hosted-netbird-embedded-idp/","summary":"One container for management, signal, relay and identity; the dashboard behind the same Caddy edge; no extra ports. It all worked on the first deploy — except login, because the owner password must be a bcrypt hash.","title":"Self-hosted NetBird with the embedded IdP (and the bcrypt gotcha)","url":"https://divyam.top/blog/self-hosted-netbird-embedded-idp/"},{"authors":[{"name":"Divyam Azad","url":"https://divyam.top/"}],"content_html":"\u003cp\u003eThe server behind this site has exactly one thing listening on the internet: Caddy, on ports 80 and 443. Everything else is reachable only on the compose network. That is a small surface, but it is a surface, and the access log filled with vulnerability scanners within an hour of the first certificate being issued. This is how I put \u003ca href=\"https://www.crowdsec.net/\"\u003eCrowdSec\u003c/a\u003e in front of it, in a way that fits the rest of the repository: declared in git, bootstrapped by the deploy script, visible on a public page.\u003c/p\u003e\n\u003ch2 id=\"the-shape\"\u003eThe shape\u003c/h2\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode\u003eCaddy ──JSON access log──▶ shared volume ──▶ crowdsec agent ──decisions──▶ Caddy (bouncer module)\n                                                   │\n                                                   └─▶ community API (signals out, blocklist in)\n\u003c/code\u003e\u003c/pre\u003e\u003cp\u003eThree pieces, all in \u003ccode\u003edocker-compose.yml\u003c/code\u003e:\u003c/p\u003e\n\u003col\u003e\n\u003cli\u003e\u003cstrong\u003eCaddy writes a JSON access log\u003c/strong\u003e to a named volume. A small snippet is imported by every site block:\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#e6edf3;background-color:#0d1117;-moz-tab-size:2;-o-tab-size:2;tab-size:2;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-caddyfile\" data-lang=\"caddyfile\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#79c0ff\"\u003e(access_log)\u003c/span\u003e {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#ff7b72\"\u003elog\u003c/span\u003e {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        \u003cspan style=\"color:#ff7b72\"\u003eoutput\u003c/span\u003e \u003cspan style=\"color:#a5d6ff\"\u003efile\u003c/span\u003e \u003cspan style=\"color:#a5d6ff\"\u003e/var/log/caddy/access.log\u003c/span\u003e { \u003cspan style=\"color:#ff7b72\"\u003eroll_size\u003c/span\u003e \u003cspan style=\"color:#a5d6ff\"\u003e10mb\u003c/span\u003e  \u003cspan style=\"color:#a5d6ff\"\u003eroll_keep\u003c/span\u003e \u003cspan style=\"color:#a5d6ff\"\u003e3\u003c/span\u003e  \u003cspan style=\"color:#a5d6ff\"\u003emode\u003c/span\u003e \u003cspan style=\"color:#a5d6ff\"\u003e0644\u003c/span\u003e \u003cspan style=\"color:#a5d6ff\"\u003e}\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        \u003cspan style=\"color:#ff7b72\"\u003eformat\u003c/span\u003e \u003cspan style=\"color:#a5d6ff\"\u003ejson\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    }\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e}\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eThe \u003ccode\u003ecrowdsec\u003c/code\u003e container\u003c/strong\u003e tails that file (\u003ccode\u003eacquis.yaml\u003c/code\u003e with \u003ccode\u003etype: caddy\u003c/code\u003e), runs the collections it needs — \u003ccode\u003ecrowdsecurity/caddy\u003c/code\u003e, \u003ccode\u003ebase-http-scenarios\u003c/code\u003e, \u003ccode\u003ehttp-cve\u003c/code\u003e, \u003ccode\u003ewhitelist-good-actors\u003c/code\u003e, and \u003ccode\u003elinux\u003c/code\u003e for the base parsers — and exchanges signals with CrowdSec\u0026rsquo;s central API: what this box sees goes out, the community blocklist comes in. It exposes its Local API on the compose network only.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eCaddy is the bouncer.\u003c/strong\u003e Instead of a separate process, the Caddy image is built on the box from a tiny Dockerfile that adds \u003ca href=\"https://github.com/hslatman/caddy-crowdsec-bouncer\"\u003ehslatman/caddy-crowdsec-bouncer\u003c/a\u003e with \u003ccode\u003excaddy\u003c/code\u003e. A global block points it at the Local API, and \u003ccode\u003ecrowdsec\u003c/code\u003e is the first directive of every site, so a banned address gets \u003ccode\u003e403\u003c/code\u003e before routing, file serving or any proxy runs:\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#e6edf3;background-color:#0d1117;-moz-tab-size:2;-o-tab-size:2;tab-size:2;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-caddyfile\" data-lang=\"caddyfile\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e{\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#ff7b72\"\u003eorder\u003c/span\u003e \u003cspan style=\"color:#a5d6ff\"\u003ecrowdsec\u003c/span\u003e \u003cspan style=\"color:#79c0ff;font-weight:bold\"\u003efirst\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#ff7b72\"\u003ecrowdsec\u003c/span\u003e {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        \u003cspan style=\"color:#ff7b72\"\u003eapi_url\u003c/span\u003e http://crowdsec:\u003cspan style=\"color:#a5d6ff\"\u003e8080\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        \u003cspan style=\"color:#ff7b72\"\u003eapi_key\u003c/span\u003e \u003cspan style=\"color:#79c0ff\"\u003e{$CROWDSEC_CADDY_BOUNCER_KEY:bootstrap-pending}\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        \u003cspan style=\"color:#ff7b72\"\u003eticker_interval\u003c/span\u003e \u003cspan style=\"color:#a5d6ff\"\u003e15s\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    }\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e}\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/li\u003e\n\u003c/ol\u003e\n\u003cp\u003eThe whole thing costs about 100 MB of RAM at rest and a one-time, roughly five-minute Caddy build on two vCPUs.\u003c/p\u003e\n\u003ch2 id=\"bootstrapping-keys-without-a-human\"\u003eBootstrapping keys without a human\u003c/h2\u003e\n\u003cp\u003eBouncers authenticate to the Local API with a key that only \u003ccode\u003ecscli\u003c/code\u003e inside the agent can mint. That is a chicken-and-egg problem for a pull-based deploy: Caddy must start before the key exists, and the key must exist before Caddy can enforce anything.\u003c/p\u003e\n\u003cp\u003eThe Caddyfile above solves the first half — note the default value in \u003ccode\u003e{$CROWDSEC_CADDY_BOUNCER_KEY:bootstrap-pending}\u003c/code\u003e. With a placeholder key Caddy starts and serves normally; the bouncer just logs that it cannot authenticate. The site never goes down because of the security layer being half-configured.\u003c/p\u003e\n\u003cp\u003eThe deploy script solves the second half. After \u003ccode\u003edocker compose up\u003c/code\u003e, it checks whether the secrets file has both keys; if not, it waits for the Local API to answer, runs \u003ccode\u003ecscli bouncers add caddy\u003c/code\u003e (and one more read-only bouncer for the security page), writes the file with mode 600, and recreates the two containers that consume it. Every run after that is a no-op. If the file is ever lost, the same function re-registers the bouncers with fresh keys.\u003c/p\u003e\n\u003ch2 id=\"a-page-for-it\"\u003eA page for it\u003c/h2\u003e\n\u003cp\u003eBecause CrowdSec\u0026rsquo;s own console is a hosted service and its old local dashboard is heavy, I added a tiny Go service that reads the Local API with a \u003cstrong\u003eread-only bouncer key\u003c/strong\u003e and renders what is being blocked right now: active decisions, how many were detected by this box versus pulled from the community, the top scenarios, and a sample of current bans with the addresses masked. Browsers get HTML; \u003ccode\u003ecurl\u003c/code\u003e gets JSON — the same content negotiation the status page uses. It cannot ban or unban anything; that is deliberate for something anyone can open.\u003c/p\u003e\n\u003ch2 id=\"the-first-real-ban\"\u003eThe first real ban\u003c/h2\u003e\n\u003cp\u003eAbout ten minutes after the first deploy, \u003ccode\u003ecscli decisions list\u003c/code\u003e showed a ban I had not created: a scanner that had been probing for a Jira vulnerability path (\u003ccode\u003ecrowdsecurity/jira_cve-2021-26086\u003c/code\u003e), then tripped \u003ccode\u003ehttp-probing\u003c/code\u003e as it walked through the rest of its list. The access log showed the requests; the decision showed the outcome; the security page showed both. Nothing to do — that is the point.\u003c/p\u003e\n\u003cp\u003eThen the end-to-end test, with my own address:\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#e6edf3;background-color:#0d1117;-moz-tab-size:2;-o-tab-size:2;tab-size:2;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-bash\" data-lang=\"bash\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003edocker compose exec crowdsec cscli decisions add --ip \u0026lt;my-ip\u0026gt; --duration 2m --reason test\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003ecurl -sI https://divyam.top/ | head -1        \u003cspan style=\"color:#8b949e;font-style:italic\"\u003e# HTTP/2 403  (within 15 s)\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003edocker compose exec crowdsec cscli decisions delete --ip \u0026lt;my-ip\u0026gt;\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003ecurl -sI https://divyam.top/ | head -1        \u003cspan style=\"color:#8b949e;font-style:italic\"\u003e# HTTP/2 200\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003ch2 id=\"the-ops-cheatsheet\"\u003eThe ops cheatsheet\u003c/h2\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#e6edf3;background-color:#0d1117;-moz-tab-size:2;-o-tab-size:2;tab-size:2;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-bash\" data-lang=\"bash\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003edocker compose exec crowdsec cscli metrics           \u003cspan style=\"color:#8b949e;font-style:italic\"\u003e# are caddy lines being parsed?\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003edocker compose exec crowdsec cscli bouncers list     \u003cspan style=\"color:#8b949e;font-style:italic\"\u003e# caddy + secstats, \u0026#34;validated\u0026#34;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003edocker compose exec crowdsec cscli decisions list    \u003cspan style=\"color:#8b949e;font-style:italic\"\u003e# who is banned right now\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003edocker compose exec crowdsec cscli alerts list       \u003cspan style=\"color:#8b949e;font-style:italic\"\u003e# what tripped, and when\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003edocker compose logs -f crowdsec                      \u003cspan style=\"color:#8b949e;font-style:italic\"\u003e# parsing, CAPI sync, new decisions\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003ch2 id=\"the-one-trap\"\u003eThe one trap\u003c/h2\u003e\n\u003cp\u003eA custom Caddy build means the stock \u003ccode\u003ecaddy validate\u003c/code\u003e cannot parse your Caddyfile any more — it does not know the \u003ccode\u003ecrowdsec\u003c/code\u003e directive. If the merged configuration has a typo, the new container fails to start and the edge is down. So the deploy that introduces the module is the one to pre-flight: build the image once on the box, run \u003ccode\u003ecaddy validate\u003c/code\u003e \u003cem\u003ewith that image\u003c/em\u003e against the merged Caddyfile, and only then let the real deploy recreate the container. After that, the build is cached and validation is a normal part of the loop.\u003c/p\u003e\n\u003cp\u003eEverything else was uneventful, which is the nicest thing one can say about a security change.\u003c/p\u003e\n","date_modified":"2026-08-22T08:55:00+05:30","date_published":"2026-08-22T08:55:00+05:30","id":"https://divyam.top/blog/crowdsec-on-a-small-vps/","summary":"An exposed Caddy edge, a log-driven intrusion prevention layer, and a deploy script that registers its own bouncer keys. It caught a real scanner within minutes — here is the whole setup, including the one trap.","title":"CrowdSec on a 4 GB VPS in an afternoon","url":"https://divyam.top/blog/crowdsec-on-a-small-vps/"},{"authors":[{"name":"Divyam Azad","url":"https://divyam.top/"}],"content_html":"\u003cp\u003eA small Telegram bot I wrote — a household assistant that lives entirely on button taps — used to run as a native binary on an old laptop under launchd. When I set up a proper server, I wanted to move it there. Moving it is not hard; moving it \u003cem\u003ewithout two copies running at once\u003c/em\u003e takes a little care.\u003c/p\u003e\n\u003ch2 id=\"the-one-rule\"\u003eThe one rule\u003c/h2\u003e\n\u003cp\u003eTelegram bots that use long polling call \u003ccode\u003egetUpdates\u003c/code\u003e in a loop. The API hands each update to exactly one caller, and if two processes poll the same token they fight: each receives \u003ccode\u003e409 Conflict\u003c/code\u003e errors, updates land on whichever poller asked first, and users see buttons that sometimes work. Nothing is corrupted, but it is a bad experience.\u003c/p\u003e\n\u003cp\u003eSo the order matters: \u003cstrong\u003estop the old poller, then start the new one.\u003c/strong\u003e If the old host is unreachable when you want to migrate — asleep, powered off, in a drawer — you need a plan for when it wakes up.\u003c/p\u003e\n\u003ch2 id=\"the-state\"\u003eThe state\u003c/h2\u003e\n\u003cp\u003eThe bot keeps everything in one SQLite database: who is paired with whom, the history of requests, preferences. Photos it forwards are stored as Telegram \u003ccode\u003efile_id\u003c/code\u003es, not files, so there was nothing else to move. The database is snapshotted nightly with \u003ccode\u003eVACUUM INTO\u003c/code\u003e, and the snapshot is the thing to carry across.\u003c/p\u003e\n\u003cp\u003eOn the new host the bot runs in a container with its database on a named volume. Seeding it is a two-step:\u003c/p\u003e\n\u003col\u003e\n\u003cli\u003e\u003ccode\u003edocker compose up --no-start\u003c/code\u003e so that Compose creates the volume and owns it (pre-creating the volume by hand makes Compose refuse to use it).\u003c/li\u003e\n\u003cli\u003eCopy the snapshot into the volume and \u003ccode\u003echown\u003c/code\u003e it to the bot\u0026rsquo;s non-root uid, because a volume populated by hand does not inherit ownership from the image the way an empty one does.\u003c/li\u003e\n\u003c/ol\u003e\n\u003ch2 id=\"the-safety-catch\"\u003eThe safety catch\u003c/h2\u003e\n\u003cp\u003eThe container is defined behind a Compose \u003cstrong\u003eprofile\u003c/strong\u003e. With the profile off, the bot is built and ready but not running; flipping it on is one line in the server-local \u003ccode\u003e.env\u003c/code\u003e. That means the whole deployment can be prepared, verified and even built in advance, and the actual switch is deliberate rather than a side effect of a deploy.\u003c/p\u003e\n\u003cp\u003eI also moved the bot\u0026rsquo;s health listener from the container\u0026rsquo;s loopback to all interfaces — it still publishes no ports, but the status page can now probe it from inside the compose network, and the first thing it reports after a switch is whether the bot is polling.\u003c/p\u003e\n\u003ch2 id=\"what-happened\"\u003eWhat happened\u003c/h2\u003e\n\u003cp\u003eThe old laptop was closed when I did the move, which is the easy case: nothing was polling, so I seeded the volume and turned the profile on. The bot came up, logged \u003ccode\u003elong polling\u003c/code\u003e, and wrote its first snapshot within ten seconds. Zero \u003ccode\u003e409\u003c/code\u003es in the logs since — which is the signal that tells me the old copy has not woken up.\u003c/p\u003e\n\u003cp\u003eOne thing did look alarming for about a minute. On the very first start the bot crash-looped with \u003ccode\u003egetMe … context deadline exceeded\u003c/code\u003e: the Telegram client gives itself five seconds to say hello, and the Whisper container starting next to it was loading its model at the same time. I spent longer than I should have proving that Telegram was reachable from the container (it was, instantly, from every variant I tried) before noticing that the failures stopped the moment Whisper finished loading. The restart policy healed it without help; the lesson was to read the neighbours\u0026rsquo; logs before suspecting the network.\u003c/p\u003e\n\u003cp\u003eThe remaining step is permanent: on the old machine, \u003ccode\u003elaunchctl unload -w\u003c/code\u003e so the agent does not start again at login. Until that runs, the status page will tell me if it does.\u003c/p\u003e\n\u003ch2 id=\"things-i-would-do-again\"\u003eThings I would do again\u003c/h2\u003e\n\u003cul\u003e\n\u003cli\u003eTreat the token as a lock. One poller, switched deliberately.\u003c/li\u003e\n\u003cli\u003eSeed from the snapshot, not the live file. SQLite in WAL mode has companion files; the snapshot is a single consistent file.\u003c/li\u003e\n\u003cli\u003eGate the new instance behind a profile so \u0026ldquo;deployed\u0026rdquo; and \u0026ldquo;running\u0026rdquo; are separate decisions.\u003c/li\u003e\n\u003cli\u003eWatch for \u003ccode\u003e409\u003c/code\u003e as the health signal for \u0026ldquo;someone else is holding the lock\u0026rdquo;.\u003c/li\u003e\n\u003c/ul\u003e\n","date_modified":"2026-08-22T08:50:00+05:30","date_published":"2026-08-22T08:50:00+05:30","id":"https://divyam.top/blog/moving-a-telegram-bot-between-hosts/","summary":"Long polling allows exactly one poller per token. Moving a bot is therefore a small choreography: stop, seed, switch on — in that order, with a safety catch.","title":"Moving a Telegram bot between hosts without losing a message","url":"https://divyam.top/blog/moving-a-telegram-bot-between-hosts/"},{"authors":[{"name":"Divyam Azad","url":"https://divyam.top/"}],"content_html":"\u003cp\u003eA small one, but it bit hard enough to be worth writing down.\u003c/p\u003e\n\u003ch2 id=\"the-setup\"\u003eThe setup\u003c/h2\u003e\n\u003cp\u003eCaddy runs as a container with its configuration bind-mounted from the repository:\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#e6edf3;background-color:#0d1117;-moz-tab-size:2;-o-tab-size:2;tab-size:2;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-yaml\" data-lang=\"yaml\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#7ee787\"\u003evolumes\u003c/span\u003e:\u003cspan style=\"color:#6e7681\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#6e7681\"\u003e  \u003c/span\u003e- \u003cspan style=\"color:#a5d6ff\"\u003e./caddy/Caddyfile:/etc/caddy/Caddyfile:ro\u003c/span\u003e\u003cspan style=\"color:#6e7681\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eThe deploy script pulls the repository and, because Compose cannot see the contents of a bind mount changing, follows \u003ccode\u003edocker compose up -d\u003c/code\u003e with a graceful \u003ccode\u003ecaddy reload\u003c/code\u003e. I added a \u003ccode\u003erespond /healthz 200\u003c/code\u003e line, pushed, watched the deploy succeed and the reload report no errors — and \u003ccode\u003e/healthz\u003c/code\u003e still returned 404.\u003c/p\u003e\n\u003ch2 id=\"what-was-actually-happening\"\u003eWhat was actually happening\u003c/h2\u003e\n\u003cp\u003eInside the container, \u003ccode\u003e/etc/caddy/Caddyfile\u003c/code\u003e did not contain the new line. The file on the host did.\u003c/p\u003e\n\u003cp\u003eThe reason is how git writes files. When it updates a tracked file it does not edit it in place; it writes a new file and renames it over the old path. Rename gives the path a \u003cstrong\u003enew inode\u003c/strong\u003e. A bind mount of a single file, however, is attached to the inode that existed when the container started. After the pull, the host path pointed at the new inode while the container was still looking at the old one — which was unchanged, and now unlinked from any path on the host.\u003c/p\u003e\n\u003cp\u003e\u003ccode\u003ecaddy reload\u003c/code\u003e did exactly what it was told: it re-read the file it could see, found it unchanged, and logged \u0026ldquo;config is unchanged\u0026rdquo;.\u003c/p\u003e\n\u003ch2 id=\"how-to-see-it\"\u003eHow to see it\u003c/h2\u003e\n\u003cp\u003eThe check that settles it takes ten seconds. Compare what the container sees with what the host has, and compare inode numbers:\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#e6edf3;background-color:#0d1117;-moz-tab-size:2;-o-tab-size:2;tab-size:2;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-bash\" data-lang=\"bash\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003edocker compose exec caddy grep -c healthz /etc/caddy/Caddyfile   \u003cspan style=\"color:#8b949e;font-style:italic\"\u003e# 0 — the container\u0026#39;s copy\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003egrep -c healthz caddy/Caddyfile                                  \u003cspan style=\"color:#8b949e;font-style:italic\"\u003e# 1 — the host\u0026#39;s copy\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003estat -c %i caddy/Caddyfile                                       \u003cspan style=\"color:#8b949e;font-style:italic\"\u003e# a new inode after every git pull\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eIf the host\u0026rsquo;s inode changes on each pull and the container keeps serving the old content, you are looking at this bug, not at a reload problem. It is easy to chase the wrong thing here: the reload returns success, the logs are clean, and \u003ccode\u003edocker compose up -d\u003c/code\u003e reports the container as unchanged — which, from Compose\u0026rsquo;s point of view, it is.\u003c/p\u003e\n\u003ch2 id=\"the-fix\"\u003eThe fix\u003c/h2\u003e\n\u003cp\u003eMount the directory, not the file:\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#e6edf3;background-color:#0d1117;-moz-tab-size:2;-o-tab-size:2;tab-size:2;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-yaml\" data-lang=\"yaml\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#7ee787\"\u003evolumes\u003c/span\u003e:\u003cspan style=\"color:#6e7681\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#6e7681\"\u003e  \u003c/span\u003e- \u003cspan style=\"color:#a5d6ff\"\u003e./caddy:/etc/caddy:ro\u003c/span\u003e\u003cspan style=\"color:#6e7681\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eA directory bind mount is attached to the directory\u0026rsquo;s inode; files inside it are looked up by name on every access, so a rename inside the directory is visible immediately. After this change the same reload picked up the new Caddyfile at once.\u003c/p\u003e\n\u003ch2 id=\"the-general-rule\"\u003eThe general rule\u003c/h2\u003e\n\u003cp\u003eIf a process or a tool replaces files by rename — git, most editors, atomic-write helpers, anything using \u003ccode\u003emv\u003c/code\u003e over a path — a single-file bind mount will go stale. Mount the parent directory and point the application at the path inside it. This holds for Docker, for Kubernetes \u003ccode\u003esubPath\u003c/code\u003e mounts, and for anything else that resolves the mount target once.\u003c/p\u003e\n\u003cp\u003eThe reload step was still right to keep. Compose recreates a container only when its \u003cem\u003econfiguration\u003c/em\u003e changes; a file that changed underneath a bind mount is invisible to it. Directory mount plus explicit reload is the combination that makes configuration changes land without restarting the edge.\u003c/p\u003e\n","date_modified":"2026-08-22T08:45:00+05:30","date_published":"2026-08-22T08:45:00+05:30","id":"https://divyam.top/blog/the-bind-mount-that-pinned-an-inode/","summary":"I edited the Caddyfile, the deploy ran, Caddy reloaded — and nothing changed. Mounting a single file is not the same as mounting its directory.","title":"The bind mount that pinned an inode","url":"https://divyam.top/blog/the-bind-mount-that-pinned-an-inode/"},{"authors":[{"name":"Divyam Azad","url":"https://divyam.top/"}],"content_html":"\u003cp\u003eMy deploy loop is deliberately boring: a systemd timer pulls a repository every five minutes, computes a fingerprint, and runs \u003ccode\u003edocker compose up -d --build\u003c/code\u003e only when something changed. Compose, in turn, is supposed to recreate only the containers whose configuration or image changed. So when I pushed a README-only commit and watched the Telegram bot restart, something was wrong.\u003c/p\u003e\n\u003ch2 id=\"what-i-saw\"\u003eWhat I saw\u003c/h2\u003e\n\u003cp\u003e\u003ccode\u003edocker compose up -d --build\u003c/code\u003e printed \u003ccode\u003eContainer voyager-golu-1 Recreate\u003c/code\u003e even though the bot\u0026rsquo;s source had not changed. The stt and caddy containers next to it stayed \u003ccode\u003eRunning\u003c/code\u003e. The bot came back within a second — it tolerates restarts well — but \u0026ldquo;every deploy bounces production\u0026rdquo; is exactly the kind of thing a pull-based loop must not do.\u003c/p\u003e\n\u003ch2 id=\"narrowing-it-down\"\u003eNarrowing it down\u003c/h2\u003e\n\u003cp\u003eFirst suspect: the build context. If anything under the bot\u0026rsquo;s checkout changed between builds, \u003ccode\u003eCOPY . .\u003c/code\u003e would invalidate the cache and produce a new image. But the repository was clean, \u003ccode\u003e.dockerignore\u003c/code\u003e excluded \u003ccode\u003e.git\u003c/code\u003e, and \u003ccode\u003efind -newer\u003c/code\u003e showed nothing touched.\u003c/p\u003e\n\u003cp\u003eSecond suspect: the build itself. Two back-to-back \u003ccode\u003edocker compose build\u003c/code\u003e runs with no changes produced two different image IDs — while \u003ccode\u003edocker image inspect\u003c/code\u003e showed the \u003cem\u003elayers and the config were byte-identical\u003c/em\u003e and the image\u0026rsquo;s \u003ccode\u003eCreated\u003c/code\u003e timestamp never moved. Every build step was \u003ccode\u003eCACHED\u003c/code\u003e. So the ID was changing without anything inside the image changing.\u003c/p\u003e\n\u003cp\u003eThat can only happen if the ID is not the image config digest anymore.\u003c/p\u003e\n\u003cp\u003eThe reproduction is three lines, and it is worth keeping around:\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#e6edf3;background-color:#0d1117;-moz-tab-size:2;-o-tab-size:2;tab-size:2;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-bash\" data-lang=\"bash\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#ff7b72\"\u003efor\u003c/span\u003e i in \u003cspan style=\"color:#a5d6ff\"\u003e1\u003c/span\u003e 2; \u003cspan style=\"color:#ff7b72\"\u003edo\u003c/span\u003e docker compose build -q golu; docker images golu:latest --format \u003cspan style=\"color:#a5d6ff\"\u003e\u0026#39;{{.ID}}\u0026#39;\u003c/span\u003e; \u003cspan style=\"color:#ff7b72\"\u003edone\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003edocker image inspect golu:latest --format \u003cspan style=\"color:#a5d6ff\"\u003e\u0026#39;{{.RootFS.Layers}}\u0026#39;\u003c/span\u003e   \u003cspan style=\"color:#8b949e;font-style:italic\"\u003e# identical both times\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eTwo different IDs, one set of layers.\u003c/p\u003e\n\u003ch2 id=\"the-cause\"\u003eThe cause\u003c/h2\u003e\n\u003cp\u003eDocker 29 uses the containerd image store by default. With it, the \u0026ldquo;image ID\u0026rdquo; Compose compares is the digest of the top-level manifest list that \u003ccode\u003ebuildx\u003c/code\u003e exports — and by default \u003ccode\u003ebuildx\u003c/code\u003e attaches a provenance attestation manifest to that list. The attestation carries build metadata, including timestamps, so it is different on every build; the manifest list digest therefore changes on every build; Compose sees a new image; the container is recreated.\u003c/p\u003e\n\u003cp\u003eNothing was wrong with the cache. The ID was simply measuring something that included \u0026ldquo;when did you run this build\u0026rdquo;.\u003c/p\u003e\n\u003ch2 id=\"the-fix\"\u003eThe fix\u003c/h2\u003e\n\u003cp\u003eDisable the default attestations for the build:\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#e6edf3;background-color:#0d1117;-moz-tab-size:2;-o-tab-size:2;tab-size:2;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-bash\" data-lang=\"bash\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eexport \u003cspan style=\"color:#79c0ff\"\u003eBUILDX_NO_DEFAULT_ATTESTATIONS\u003c/span\u003e\u003cspan style=\"color:#ff7b72;font-weight:bold\"\u003e=\u003c/span\u003e\u003cspan style=\"color:#a5d6ff\"\u003e1\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003edocker compose up -d --build\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eWith attestations off, the exported artifact is just the image, and its ID is stable as long as the layers are. Two builds in a row now yield the same ID, and a forced redeploy reports \u003ccode\u003eContainer voyager-golu-1 Running\u003c/code\u003e instead of \u003ccode\u003eRecreate\u003c/code\u003e.\u003c/p\u003e\n\u003cp\u003eTwo footnotes from the investigation:\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003eCompose\u0026rsquo;s \u003ccode\u003ebuild.provenance: false\u003c/code\u003e looked like the right knob, but in the version I had it did not change the outcome. The environment variable did, so that is what lives in the deploy script, with a comment explaining why.\u003c/li\u003e\n\u003cli\u003eIf you want attestations for supply-chain reasons, the alternative is to stop comparing by image ID — for example, tag builds with the source commit and let Compose recreate when the tag changes. I did not need the attestations on a single-host setup, so I turned them off.\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch2 id=\"the-lesson\"\u003eThe lesson\u003c/h2\u003e\n\u003cp\u003e\u0026ldquo;Only what changed should restart\u0026rdquo; is a property you have to test, not assume. The cheapest test is the one I should have run on day one: deploy twice with no changes and read the output. Anything that says \u003ccode\u003eRecreate\u003c/code\u003e the second time is a bug.\u003c/p\u003e\n","date_modified":"2026-08-22T08:40:00+05:30","date_published":"2026-08-22T08:40:00+05:30","id":"https://divyam.top/blog/docker-29-made-my-bot-restart-on-every-deploy/","summary":"An unchanged image kept getting a new ID. The culprit was the build attestation the containerd image store folds into the manifest list — and the fix is one environment variable.","title":"Docker 29 made my bot restart on every deploy","url":"https://divyam.top/blog/docker-29-made-my-bot-restart-on-every-deploy/"},{"authors":[{"name":"Divyam Azad","url":"https://divyam.top/"}],"content_html":"\u003cp\u003eEverything that runs behind \u003cstrong\u003edivyam.top\u003c/strong\u003e 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.\u003c/p\u003e\n\u003cp\u003eThis post is the written version of the architecture page that used to live at \u003ccode\u003e/infra/\u003c/code\u003e: 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.\u003c/p\u003e\n\u003ch2 id=\"the-shape-of-it\"\u003eThe shape of it\u003c/h2\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode\u003e  laptop                                  GitHub (private repos, ssh)\n  ──────                                  ──────────────────────────\n  edit ─ git push ──────────────────────▶ voyager   (this repo: how things run)\n  make deploy = push + ssh \u0026#34;deploy.sh\u0026#34;    golu      (app source, listed in apps.txt)\n                                                │\n                                                │ git pull · one read-only deploy key per repo\n                                                ▼\n ┌─ VPS · Ubuntu 24.04 · 2 vCPU / 4 GB ───────────────────────────────────────────────┐\n │                                                                                     │\n │  systemd  voyager-deploy.timer ── every 5 min ──▶ scripts/deploy.sh                 │\n │           pull voyager + apps/* ─▶ anything changed? ─▶ docker compose up -d --build│\n │                                                          + caddy reload            │\n │  docker compose project \u0026#34;voyager\u0026#34; (one private network)                             │\n │                                                                                     │\n │   internet ──▶ :80 :443 ──▶ ┌───────────┐  portfolio (site/) · blog (state/blog)    │\n │        (tcp + udp for h3)   │   caddy   │  TLS · redirects · headers · proxies      │\n │                             └───────────┘                                           │\n │       ┌───────────┐   ┌───────────┐   ┌───────────┐   ┌───────────┐                 │\n │       │   golu    │──▶│    stt    │   │   gatus   │◀──│statusjson │                 │\n │       │ telegram  │   │  whisper  │   │  status   │   │ curl view │                 │\n │       └───────────┘   └───────────┘   └───────────┘   └───────────┘                 │\n │        no ports        expose 9000     expose 8080     expose 8081                  │\n │                                                                                     │\n │  ufw 22/80/443 · fail2ban · sshd keys only · unattended-upgrades                    │\n └─────────────────────────────────────────────────────────────────────────────────────┘\n\u003c/code\u003e\u003c/pre\u003e\u003cp\u003eThree moving parts:\u003c/p\u003e\n\u003ctable\u003e\n\t\u003cthead\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003cth\u003epart\u003c/th\u003e\n\t\t\t\t\t\u003cth\u003ewhat it is\u003c/th\u003e\n\t\t\t\t\t\u003cth\u003ewhere it lives\u003c/th\u003e\n\t\t\t\u003c/tr\u003e\n\t\u003c/thead\u003e\n\t\u003ctbody\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003e\u003cstrong\u003eedge\u003c/strong\u003e\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003eCaddy — the only container with published ports; TLS, redirects, security headers, the static portfolio and this blog, reverse proxies to apps\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003e\u003ccode\u003edocker-compose.yml\u003c/code\u003e (\u003ccode\u003ecaddy\u003c/code\u003e), \u003ccode\u003ecaddy/Caddyfile\u003c/code\u003e, \u003ccode\u003esite/\u003c/code\u003e, \u003ccode\u003eblog/\u003c/code\u003e\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003e\u003cstrong\u003eapps\u003c/strong\u003e\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003eeverything else, built from source checkouts under \u003ccode\u003eapps/\u0026lt;name\u0026gt;\u003c/code\u003e; they talk to each other over the compose network and never to the internet directly\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003e\u003ccode\u003eapps.txt\u003c/code\u003e, the other compose services\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003e\u003cstrong\u003ereconciler\u003c/strong\u003e\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003ea systemd timer running \u003ccode\u003escripts/deploy.sh\u003c/code\u003e, which pulls and converges\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003e\u003ccode\u003esystemd/\u003c/code\u003e, \u003ccode\u003escripts/deploy.sh\u003c/code\u003e\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\u003c/tbody\u003e\n\u003c/table\u003e\n\u003ch2 id=\"a-requests-journey\"\u003eA request\u0026rsquo;s journey\u003c/h2\u003e\n\u003col\u003e\n\u003cli\u003e\u003cstrong\u003eDNS.\u003c/strong\u003e Plain \u003ccode\u003eA\u003c/code\u003e records at the registrar point the apex, \u003ccode\u003ewww\u003c/code\u003e, \u003ccode\u003estatus\u003c/code\u003e and \u003ccode\u003esecurity\u003c/code\u003e at the server.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eHost firewall.\u003c/strong\u003e \u003ccode\u003eufw\u003c/code\u003e allows \u003ccode\u003e22/tcp\u003c/code\u003e, \u003ccode\u003e80/tcp\u003c/code\u003e and \u003ccode\u003e443\u003c/code\u003e (TCP \u003cem\u003eand\u003c/em\u003e UDP, for HTTP/3). Worth knowing: Docker programs its own iptables chains ahead of ufw\u0026rsquo;s, so any \u003ccode\u003eports:\u003c/code\u003e 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.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eDocker port mapping.\u003c/strong\u003e Host \u003ccode\u003e:80\u003c/code\u003e and \u003ccode\u003e:443\u003c/code\u003e are DNAT\u0026rsquo;ed into the \u003ccode\u003ecaddy\u003c/code\u003e container.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eCaddy.\u003c/strong\u003e It terminates TLS with a Let\u0026rsquo;s Encrypt certificate it obtained — and renews — on its own; \u003ccode\u003ehttp://\u003c/code\u003e answers with a \u003ccode\u003e308\u003c/code\u003e to \u003ccode\u003ehttps://\u003c/code\u003e; \u003ccode\u003ewww\u003c/code\u003e answers with a \u003ccode\u003e301\u003c/code\u003e to the apex; HTTP/3 rides on \u003ccode\u003e443/udp\u003c/code\u003e. The apex block compresses responses, answers \u003ccode\u003e/healthz\u003c/code\u003e with an empty \u003ccode\u003e200\u003c/code\u003e 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 \u003ccode\u003efile_server\u003c/code\u003e from a bind mount of the repository\u0026rsquo;s \u003ccode\u003esite/\u003c/code\u003e 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.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eApps.\u003c/strong\u003e Anything web-facing gets either a \u003ccode\u003ehandle /path/* { reverse_proxy \u0026lt;service\u0026gt;:\u0026lt;port\u0026gt; }\u003c/code\u003e block or its own site block, like the status page. Service names resolve through Docker\u0026rsquo;s internal DNS on the compose network.\u003c/li\u003e\n\u003c/ol\u003e\n\u003ch2 id=\"where-the-ports-are\"\u003eWhere the ports are\u003c/h2\u003e\n\u003cp\u003eAll of the port mapping lives in \u003ccode\u003edocker-compose.yml\u003c/code\u003e, and all of it is on the \u003ccode\u003ecaddy\u003c/code\u003e service:\u003c/p\u003e\n\u003ctable\u003e\n\t\u003cthead\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003cth\u003ehost (public)\u003c/th\u003e\n\t\t\t\t\t\u003cth\u003econtainer\u003c/th\u003e\n\t\t\t\t\t\u003cth\u003eused for\u003c/th\u003e\n\t\t\t\u003c/tr\u003e\n\t\u003c/thead\u003e\n\t\u003ctbody\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003e\u003ccode\u003e:80/tcp\u003c/code\u003e\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003e\u003ccode\u003ecaddy:80\u003c/code\u003e\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003eACME HTTP-01 challenge, \u003ccode\u003ehttp → https\u003c/code\u003e redirect\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003e\u003ccode\u003e:443/tcp\u003c/code\u003e\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003e\u003ccode\u003ecaddy:443\u003c/code\u003e\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003eHTTPS (HTTP/1.1, HTTP/2)\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003e\u003ccode\u003e:443/udp\u003c/code\u003e\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003e\u003ccode\u003ecaddy:443\u003c/code\u003e\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003eHTTP/3 (QUIC)\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\u003c/tbody\u003e\n\u003c/table\u003e\n\u003cp\u003eEverything else stays inside the compose network:\u003c/p\u003e\n\u003ctable\u003e\n\t\u003cthead\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003cth\u003eservice\u003c/th\u003e\n\t\t\t\t\t\u003cth\u003ereachability\u003c/th\u003e\n\t\t\t\t\t\u003cth\u003enote\u003c/th\u003e\n\t\t\t\u003c/tr\u003e\n\t\u003c/thead\u003e\n\t\u003ctbody\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003e\u003ccode\u003ecaddy\u003c/code\u003e admin \u003ccode\u003e:2019\u003c/code\u003e\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003econtainer-local only\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003e\u003ccode\u003ecaddy reload\u003c/code\u003e talks to it after each deploy\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003e\u003ccode\u003estt\u003c/code\u003e \u003ccode\u003e:9000\u003c/code\u003e\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003e\u003ccode\u003eexpose:\u003c/code\u003e only — \u003ccode\u003ehttp://stt:9000/asr\u003c/code\u003e from golu\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003eWhisper speech-to-text for voice notes\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003e\u003ccode\u003egatus\u003c/code\u003e \u003ccode\u003e:8080\u003c/code\u003e\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003e\u003ccode\u003eexpose:\u003c/code\u003e only — Caddy proxies it as \u003ccode\u003estatus.divyam.top\u003c/code\u003e\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003ethe public status page\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003e\u003ccode\u003estatusjson\u003c/code\u003e \u003ccode\u003e:8081\u003c/code\u003e\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003e\u003ccode\u003eexpose:\u003c/code\u003e only — Caddy routes CLI user agents, \u003ccode\u003eAccept: application/json\u003c/code\u003e and \u003ccode\u003e/json\u003c/code\u003e there\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003e\u003ccode\u003ecurl https://status.divyam.top/\u003c/code\u003e returns a compact JSON summary\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003e\u003ccode\u003egolu\u003c/code\u003e\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003epublishes nothing\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003elong-polls Telegram \u003cem\u003eoutbound\u003c/em\u003e; its health listener is reachable only from the compose network\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\u003c/tbody\u003e\n\u003c/table\u003e\n\u003cp\u003eThe rule of thumb I hold myself to: \u003cstrong\u003e\u003ccode\u003eports:\u003c/code\u003e means public, \u003ccode\u003eexpose:\u003c/code\u003e means private.\u003c/strong\u003e A new web-facing app gets \u003ccode\u003eexpose:\u003c/code\u003e plus a Caddy route — never its own \u003ccode\u003eports:\u003c/code\u003e.\u003c/p\u003e\n\u003ch2 id=\"how-a-deploy-happens\"\u003eHow a deploy happens\u003c/h2\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode\u003e git push ──▶ GitHub ──▶ (≤ 5 min) voyager-deploy.timer ──▶ deploy.sh\n                                     ▲\n               make deploy ──────────┘  (push, then trigger deploy.sh over ssh)\n\u003c/code\u003e\u003c/pre\u003e\u003cp\u003e\u003ccode\u003escripts/deploy.sh\u003c/code\u003e, every run, under a lock:\u003c/p\u003e\n\u003col\u003e\n\u003cli\u003eFast-forward the repository. If \u003ccode\u003edeploy.sh\u003c/code\u003e itself changed, re-exec the new version so the same run uses it.\u003c/li\u003e\n\u003cli\u003eInstall or refresh the systemd units from \u003ccode\u003esystemd/\u003c/code\u003e if they differ.\u003c/li\u003e\n\u003cli\u003eFor each line in \u003ccode\u003eapps.txt\u003c/code\u003e (\u003ccode\u003ename url branch\u003c/code\u003e): clone into \u003ccode\u003eapps/\u0026lt;name\u0026gt;\u003c/code\u003e or fast-forward it. URLs use ssh aliases, each backed by a \u003cstrong\u003eread-only deploy key\u003c/strong\u003e — 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.\u003c/li\u003e\n\u003cli\u003eMake sure bind-mounted state directories are owned by the app\u0026rsquo;s uid (the bot runs as a non-root user in a distroless image).\u003c/li\u003e\n\u003cli\u003eCompute a fingerprint: the repository\u0026rsquo;s \u003ccode\u003eHEAD\u003c/code\u003e, every app\u0026rsquo;s \u003ccode\u003eHEAD\u003c/code\u003e, the server-local \u003ccode\u003e.env\u003c/code\u003e and the contents of \u003ccode\u003esecrets/\u003c/code\u003e. Unchanged → log \u0026ldquo;up to date\u0026rdquo; and exit. Otherwise \u003ccode\u003edocker compose pull --ignore-buildable\u003c/code\u003e, \u003ccode\u003edocker compose up -d --build --remove-orphans\u003c/code\u003e, build this blog, reload Caddy, prune images and write a small \u003ccode\u003ebuild-info.json\u003c/code\u003e that the portfolio footer reads (\u0026ldquo;deployed from \u003ccode\u003e\u0026lt;sha\u0026gt;\u003c/code\u003e · n minutes ago\u0026rdquo;).\u003c/li\u003e\n\u003c/ol\u003e\n\u003cp\u003eLocal 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.\u003c/p\u003e\n\u003ch2 id=\"what-runs-on-it\"\u003eWhat runs on it\u003c/h2\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003eThe portfolio\u003c/strong\u003e — a static page, served straight from the repository.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eThis blog\u003c/strong\u003e — Hugo source in the repository, rendered at deploy time into a directory Caddy serves under \u003ccode\u003e/blog/\u003c/code\u003e.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eA status page\u003c/strong\u003e — Gatus, configured from a YAML file in the repository, checking the edge (site, \u003ccode\u003e/healthz\u003c/code\u003e, both redirects, DNS, certificate expiry) and the bot\u0026rsquo;s health endpoint from inside the network. A tiny Go service in front of it answers \u003ccode\u003ecurl https://status.divyam.top/\u003c/code\u003e with a compact JSON document while browsers still get the dashboard.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eA Telegram bot\u003c/strong\u003e — 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.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eCrowdSec\u003c/strong\u003e — a log-driven intrusion prevention layer: it reads Caddy\u0026rsquo;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 \u003ccode\u003e403\u003c/code\u003e before anything else runs). A small read-only page at \u003ccode\u003esecurity.divyam.top\u003c/code\u003e shows what it is blocking right now.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eNetBird\u003c/strong\u003e — 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 \u003ccode\u003evpn.divyam.top\u003c/code\u003e, so my own devices and the server share a private network without any extra port on the box.\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch2 id=\"what-lives-only-on-the-server\"\u003eWhat lives only on the server\u003c/h2\u003e\n\u003cp\u003eEverything in git can be rebuilt from git. These cannot:\u003c/p\u003e\n\u003ctable\u003e\n\t\u003cthead\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003cth\u003epath / volume\u003c/th\u003e\n\t\t\t\t\t\u003cth\u003ewhat\u003c/th\u003e\n\t\t\t\u003c/tr\u003e\n\t\u003c/thead\u003e\n\t\u003ctbody\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003e\u003ccode\u003e.env\u003c/code\u003e\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003ecompose profiles and timezone — hand-written once\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003e\u003ccode\u003esecrets/\u003c/code\u003e\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003ebot token, API keys — never in git\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003e\u003ccode\u003eapps/\u003c/code\u003e\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003esource checkouts — recloned if missing\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003e\u003ccode\u003estate/\u003c/code\u003e\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003ebind mounts: the bot\u0026rsquo;s forwarded photos, nightly database snapshots, the rendered blog\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003e\u003ccode\u003egolu-data\u003c/code\u003e\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003ethe bot\u0026rsquo;s live SQLite database — its memory, snapshotted nightly\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003e\u003ccode\u003ecaddy-data\u003c/code\u003e\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003ethe Let\u0026rsquo;s Encrypt account and certificates\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003e\u003ccode\u003ewhisper-cache\u003c/code\u003e\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003ethe Whisper model, re-downloaded if lost\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003e\u003ccode\u003egatus-data\u003c/code\u003e\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003estatus history\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\u003c/tbody\u003e\n\u003c/table\u003e\n\u003cp\u003eIf the server were wiped tomorrow: bootstrap a fresh VPS with the script in the repository, add the deploy key on GitHub, copy \u003ccode\u003e.env\u003c/code\u003e and \u003ccode\u003esecrets/\u003c/code\u003e over, and the timer rebuilds the rest from git.\u003c/p\u003e\n\u003ch2 id=\"two-gotchas-worth-telling\"\u003eTwo gotchas worth telling\u003c/h2\u003e\n\u003cp\u003e\u003cstrong\u003eOnly what changed should restart.\u003c/strong\u003e It didn\u0026rsquo;t, at first: the bot was recreated on every deploy even when its code had not moved. Docker 29\u0026rsquo;s containerd image store folds a freshly generated build attestation into the image ID on every build, so Compose saw a \u0026ldquo;new image\u0026rdquo; although every layer was identical. Building with \u003ccode\u003eBUILDX_NO_DEFAULT_ATTESTATIONS=1\u003c/code\u003e makes the ID change only when the layers do. There is a longer write-up coming.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003eCaddy is reloaded, not restarted\u003c/strong\u003e — its configuration arrives through a bind mount that Compose cannot see changing. And it is the \u003cem\u003edirectory\u003c/em\u003e \u003ccode\u003ecaddy/\u003c/code\u003e 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 \u003ccode\u003e/healthz\u003c/code\u003e.\u003c/p\u003e\n\u003ch2 id=\"the-stack\"\u003eThe stack\u003c/h2\u003e\n\u003cp\u003eUbuntu 24.04 · Docker 29 + Compose · Caddy 2 · systemd timers · Go · Whisper · Gatus · Hugo · CrowdSec · NetBird · Let\u0026rsquo;s Encrypt · GitHub deploy keys · ufw and fail2ban · SQLite.\u003c/p\u003e\n\u003cp\u003eSmall on purpose. The repository is private, but I am happy to walk through it.\u003c/p\u003e\n","date_modified":"2026-08-22T08:30:00+05:30","date_published":"2026-08-22T08:30:00+05:30","id":"https://divyam.top/blog/how-this-site-is-hosted/","summary":"One small VPS, one git repository, a five-minute pull loop and a single edge: the architecture behind divyam.top, the status page and a Telegram bot that shares the box.","title":"How this site is hosted","url":"https://divyam.top/blog/how-this-site-is-hosted/"}],"language":"en","title":"Divyam Azad — notes","version":"https://jsonfeed.org/version/1.1"}