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.
Folder tree → a list of Site values.
ReadDomains scans the domains folder: a subfolder counts as a domain only if its name contains a dot; malformed ones are skipped.Discover walks a domain folder. Leaf-first Labels chain: apex = empty chain; a subfolder ending in a dot is a subdomain (docs./api./ → api.docs.yourdomain.com); multi-dot names map directly (foo.bar.).IsSPA (index.html + assets/ · static/ · _next/ · dist/), NotFoundPage (404.html), and the off cascade: a site is Disabled if its own config or any parent's says "enable": false — ask Site.Off(), never Config.Enabled() alone..zipgoconfig.json is a hard error — it aborts the whole reload, so typos surface instead of being ignored.Discover, which is why .zipgo-versions/ is never mistaken for a subdomain.Sites → one Caddy JSON config, generated in memory. No Caddyfile on disk.
BuildConfig = domain mode: one route per host, servers on :443 and :80, TLS subjects = exact hosts, ACME/Let's Encrypt automation.BuildLocalhostConfig = localhost mode (no domains, or ZIPGO_LOCALHOST=1): single port 9000, path routing <domain>/<outer>/…/<leaf>, routes sorted deepest-first.served (normal route), Gone (410 handler, host kept in the TLS subjects so the 410 is reachable over HTTPS), or nothing (an empty holder folder).www. site exists → bare domain 308-redirects to www. (hasWww).sub-domains-meta is computed at build time (childMeta + internal/meta) and embedded as a static_response → it refreshes on every watcher reload.Reverse proxy + TLS. Every module zipgo emits is imported next to where it is emitted.
reload() re-runs Discover → Build → caddy.Run(cfg). The admin API is disabled.strict_sni_host: the Host header must equal the SNI host, else 421 — otherwise a client could present a public site's SNI and ask for a locked or geo-fenced host.misdirectedRoute); :80 → 301 to HTTPS unless a site opted into allowHttp.gate refuses an unusable hash closed instead of failing provisioning.Filesystem changes are the only deploy mechanism.
fsnotify watches the domains folder recursively (every existing dir is added).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.siteHandler internal/builderOrder is load-bearing: locks run before files, the SPA fallback, a proxied upstream or a redirect.
unguardedSiteHandler picks the kind in precedence: admin → redirect → proxy → file_server (a site that sets both redirect and and rewrite redirects; doctor flags it).lockHandlers: the login page first (a visitor without a session never sees anything else), then basic auth — both run when both are set.flagsHandler comes after the locks: on a locked site the flags endpoint and the closed paths sit behind the login page.guard(route, …) prepends the same lock to a protected site's sub-domains-meta route so it can't leak the child listing.basicAuth.hide = dotfiles, .zipgoconfig.json, .zipgo-versions/, nested *. subdomain folders (never in the apex's directory listing) and escaping symlinks.index.html; browse: true is the only way to get a directory listing (never on an SPA).404.html → an errors route inside the subroute rewrites a 404 to the page and re-serves it with status_code: 404 — without it the page would be a 200 soft-404.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.
.zipgoconfig.json — the per-site switches| key | part it drives | behaviour |
|---|---|---|
| enable | builder served / Gone | false → 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. |
| rewrite | proxyHandler | the site becomes a reverse_proxy to that upstream (host:port or a URL) instead of serving files. |
| rewritePath + rewritePathPassthrough | builder.rewritePathRoute | serve 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 | redirectHandler | a 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. |
| password | internal/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. |
| basicAuth | Caddy's authentication handler | the browser's own prompt. Runs after the password gate; both run when both are set (doctor warns). |
| flags | internal/flags | name → 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. |
| admin | internal/admin | true = 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). |
| allowHttp | httpAllowed / httpsOnly | also serve on :80 instead of redirecting. Ignored for a password site (its cookie is Secure); no HSTS when set. |
| browse | fileOpts.browse | directory listing for a folder without index.html. Never on an SPA. |
| headers | securityHeaders | merged 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.
internal/builder)| default | why |
|---|---|
| HTTPS-only + HSTS | max-age 1y (no includeSubDomains), HTTPS-only unless allowHttp; a locked site is always HTTPS-only. |
| dotfileRoute | a 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. |
| escapingSymlinks | a 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: SAMEORIGIN | a <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_ALLOW | a rewrite upstream outside the allowlist answers 403 (refusedUpstreamHandler); unset = unrestricted. |
| timeouts · trusted proxies · PROXY v2 | read_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. |
no-store, noindex, strict CSP) on every path; POST /__zipgo/login checks bcrypt and sets a __Host-zipgo_session cookie; /__zipgo/logout clears it.?__zipgo_pwd=… on a GET → checked like the form, then 303 to the same URI minus the param.flags block (even empty) with flags (name → on), closed (normalised paths of the off flags) and, in localhost mode, prefix.[A-Za-z0-9_-]), paths must be plain prefixes — no globs, no ./.., not /. A hard error like headers.deploy/flags.go: SetFlag, DeclareFlag, RemoveFlag, MergeFlags — the back-office and the CLI both go through them..zipgoconfig.json files the CLI does — no state of its own.root, root_host, self, tmp_dir (+ prefix/root_path in localhost mode); "all" sends scope:"all" + domains_dir and re-reads the domains with config.ReadDomains on every request.all scope a row's ID is <domain>/<folder path>; locate splits it back and calls the unchanged Switch / SetLock / SetFlag.title, description, og:*, favicon and custom zipgo:* props from a child's index.html for the parent's sub-domains-meta..zipgo.json target, project package.json "zipgo".deploy map. Precedence --ssh > project target > root target.rsyncArgs centralises the flags: mirror --delete (the default; --prune is a clearer alias), the *./ subdomain-protection auto-exclude, user --excludes, and -n → --dry-run --itemize-changes + a per-host added/replaced/deleted summary (summary.go).flags block / unreadable config → --exclude=/.zipgoconfig.json and mergeKept (KeepServerState) republishes only the build's own keys, with "enable": false kept, server locks put back, and the server's flags winning the merge..zipgo-versions/<UTC stamp>/ first (last 5, --keep N, --no-history off)."enable" key, edited over SSH. In deploy, not remote, because a deploy must honour it.inspect runs one script printing a record per folder — the host's parents (ParentDirs), itself, and every nested *. folder — with its base64 config.SetEnable(raw, on) is pure: parses with sites.ParseConfig first (a config the server rejects is refused, never rewritten), keeps keys in file order, writes "enable": false in place, and on on deletes the file when nothing is left.writeConfig = mktemp in the domains root (same filesystem, outside every site, so a half-written file — maybe a password hash — is never served) + mv. Also mkdir -ps, so a site can be locked before its first deploy.siteDirs + one generated statScript → tab-separated records → parseSites (pure, table-tested) → Site, the single source of truth for both the human output and --json.Type / Enabled / Protected are recomputed the way builder routes the site — Enabled with the cascade (OffBy = nearest off ancestor, decided by folder nesting, not host suffix); an unreadable config surfaces as ConfigError rather than being dropped. Hashes are never emitted.Size/Files prune nested trailing-dot folders and the history folder — an apex is not the sum of its whole domain.rollback snapshots the live content first (so a rollback is itself reversible), then rsyncs the chosen snapshot back — content, never server state: a site that is off stays off, a lock stays, flags keep their switch.Report of Finding{Level, Host, Path, Msg, Hint}; Report.OK(strict) drives the exit code, so zipgo doctor && zipgo deploy gates a deploy.Discover: Discover hard-fails on one bad config, doctor must keep going and report every problem in one pass. It carries offBy down, like the server's cascade.index.html (Error, downgraded when the folder only holds subdomains), "enable": true under an off parent, malformed or unknown config keys, invalid domain/subdomain names, a dot-containing folder that forgot its trailing dot, unusable rewrite/redirect, a non-bcrypt basicAuth, the password block, a 404.html that can never be served, two folders resolving to the same host.DetectSPA/DetectNotFound are exported so doctor agrees with discovery instead of re-implementing the rules.set · status · remove · hash over a Store — Local{Base} for --dir, Remote over deploy.ReadConfig/WriteConfig. SetPassword swaps the password key and keeps every other one.--stdin or --generate, and is bcrypt-hashed client-side at cost 12 (~0.3 s per login on a Pi 5) — only the hash travels.zipgo admin set writes "admin" and a password into the same file in one go.off/on.domains/zipgo.xyz/install/{linux,macos,windows}.sh are generated files — edit scripts/parts/*.sh and run make build-install-scripts (or make build).ZIPGO_DOMAINS_FOLDER · ZIPGO_LOCALHOST · ZIPGO_METRICS(__ADDR) · ZIPGO_LOG_FORMAT=json · ZIPGO_PROXY_PROTOCOL_ALLOW · ZIPGO_TRUSTED_PROXIES · ZIPGO_REWRITE_ALLOW.go vet, the tests and govulncheck; the Dockerfile verifies the binary against the release checksums, pins alpine by digest, and drops every capability on a read-only rootfs.Read from ~/projects/zipgo (CLAUDE.md, README.md, main.go and internal/*) on 2026-11-01. Nothing was modified.