zipgo — how the parts work

A single Go binary that embeds Caddy as a library. The folder tree on disk is the routing table; Caddy's whole configuration is generated in memory and rebuilt on every change.

InfrastructureGo packages (build-time) Routing (one site = one route)Data on disk CLI over SSHCaddy modules (request-time)
.zipgo/ folder tree→ config.ReadDomains→ sites.Discover→ builder.Build*Config→ Caddy JSON in memory→ caddy.Run⇅ fsnotify watcher · reload()

1 · The pipeline

Go Discovery internal/config · internal/sites

Folder tree → a list of Site values.

Dot-prefixed folders are skipped by Discover, which is why .zipgo-versions/ is never mistaken for a subdomain.

Go Builder internal/builder

Sites → one Caddy JSON config, generated in memory. No Caddyfile on disk.

Infra Caddy runtime caddy.Run · caddyhttp/caddyauth

Reverse proxy + TLS. Every module zipgo emits is imported next to where it is emitted.

Disk The watcher main.go · watchAndReload

Filesystem changes are the only deploy mechanism.

Writes are made atomically (see deploy.writeConfig: mktemp in the domains root + mv) so the watcher can never read a half-written file — a malformed config would abort the whole reload.

2 · What one request passes through

TLS · ACME · one cert per exact subject→ route by Host (or path, localhost)→ 410 Gone? (off)→ gate password→ basicAuth→ flags 404 on closed paths→ redirect | reverse_proxy | file_server→ securityHeaders

Routing siteHandler internal/builder

Order is load-bearing: locks run before files, the SPA fallback, a proxied upstream or a redirect.

Routing file_server options fileHandler · fileOpts

3 · The on-disk schema

.zipgo/ # ZIPGO_DOMAINS_FOLDER (default .zipgo) └── yourdomain.com/ # a domain: name must contain a dot ├── index.html # the apex site ├── assets/ # ordinary content → served as paths ├── 404.html # the body of this site's 404s ├── .zipgoconfig.json # per-site settings (never served) ├── .zipgo-versions/ # deploy history (hidden, never served) │ └── 2026-11-01T09:12Z/ # one snapshot per deploy (last N, --keep) ├── docs./ # → docs.yourdomain.com (trailing dot = subdomain) │ └── api./ # → api.docs.yourdomain.com (recursive) ├── blog./ # → blog.yourdomain.com ├── www./ # exists → bare domain 308-redirects to www. └── zipgo./ # "admin": true → this folder is the domain's back-office └── index.html # the phone-made page that drives its API

Folders that do not end in a dot are plain content of the parent site. A folder with a dot in the name that forgot its trailing dot is a doctor error.

4 · .zipgoconfig.json — the per-site switches

keypart it drivesbehaviour
enablebuilder served / Gonefalse → the site and its whole subtree answer 410 Gone, keep their certs, drop out of the parent's sub-domains-meta. An explicit true below an off parent cannot override it. Written by zipgo off / on.
rewriteproxyHandlerthe site becomes a reverse_proxy to that upstream (host:port or a URL) instead of serving files.
rewritePath
+ rewritePathPassthrough
builder.rewritePathRouteserve a sub-path of the upstream at the site's root with the URL staying put (PocketBase's /_/). The passthrough list (default ["/api"]) is load-bearing: the app still calls the upstream's root absolutely.
redirect
+ redirectStatus
redirectHandlera bare origin keeps the request's path and query (/docs?x=1 survives a domain move); a target with a path is used verbatim. Default 302, so a mistake isn't cached forever.
passwordinternal/gate{"hash","title","sessionDays"} → the site behind a login page. bcrypt hash only, HMAC(server key, host, hash) session cookie; a new salt on every set logs everyone out. HTTPS-only.
basicAuthCaddy's authentication handlerthe browser's own prompt. Runs after the password gate; both run when both are set (doctor warns).
flagsinternal/flagsname → bool or {"on","description","paths"}. Answered at /__zipgo/flags.json; an off flag's paths return a plain 404 before files, the SPA fallback or a proxy. No state — a flip is a config edit + reload.
admininternal/admintrue = back-office for the site its folder is nested in and every subdomain under it; "all" = every domain in the domains folder. Needs a password (zipgo admin set writes both).
allowHttphttpAllowed / httpsOnlyalso serve on :80 instead of redirecting. Ignored for a password site (its cookie is Secure); no HSTS when set.
browsefileOpts.browsedirectory listing for a folder without index.html. Never on an SPA.
headerssecurityHeadersmerged into the defaults, never a replacement; case-insensitive match reuses the existing spelling; an empty value deletes the header (and adds it to Caddy's delete list).

Adding a key moves three places together: sites.Config · doctor.knownConfigKeys (+ knownKeyList, so a typo'd key is reported as ignored) · the README's per-site-config section. And: enable, password, basicAuth and flags are server state — a deploy never overwrites them.

5 · Security defaults (all on, in internal/builder)

defaultwhy
HTTPS-only + HSTSmax-age 1y (no includeSubDomains), HTTPS-only unless allowHttp; a locked site is always HTTPS-only.
dotfileRoutea path containing /. is refused before any file server, SPA fallback or custom 404 (/.well-known/* exempt) — through the error handler so a static site's 404.html still answers.
escapingSymlinksa link whose target leaves the site folder or enters a nested trailing-dot site is hidden — one could otherwise serve the cert store, the session key or a locked sibling. zipgo deploy also passes rsync --safe-links.
X-Frame-Options: SAMEORIGINa <meta property="zipgo:authorized-origins"> in index.html drops it and emits Content-Security-Policy: frame-ancestors 'self' … instead — host patterns, not regex.
ZIPGO_REWRITE_ALLOWa rewrite upstream outside the allowlist answers 403 (refusedUpstreamHandler); unset = unrestricted.
timeouts · trusted proxies · PROXY v2read_header_timeout 15s, idle_timeout 2m; X-Forwarded-For believed only for ZIPGO_TRUSTED_PROXIES; ZIPGO_PROXY_PROTOCOL_ALLOW for a TCP passthrough in front (the raspy2 hop).
access-log redaction?__zipgo_pwd=… is stripped from the logged URI by Caddy's filter encoder; metrics on loopback only; bcrypt cost 12.

6 · Caddy modules zipgo registers itself

Module gate internal/gate · http.handlers.zipgo_password

Module flags internal/flags · http.handlers.zipgo_flags

Module admin internal/admin · http.handlers.zipgo_admin

Module meta internal/meta

7 · The CLI parts (over SSH — the other half of the binary)

CLI deploy internal/deploy · internal/zipconfig

CLI off · on internal/deploy/switch.go

CLI ls · info · rollback internal/remote

CLI doctor internal/doctor

DetectSPA/DetectNotFound are exported so doctor agrees with discovery instead of re-implementing the rules.

CLI password · admin internal/password

CLI enable · disable · status internal/service

External build parts scripts/parts · Makefile · docker

Read from ~/projects/zipgo (CLAUDE.md, README.md, main.go and internal/*) on 2026-11-01. Nothing was modified.