README
¶
Work in progress — not yet ready for production use.
doors-caddy
Caddy v2 modules for Doors apps: zero-interruption rolling deployments with cookie-based load balancing, and geo-IP redirects.
Features
- Zero-interruption rollouts — draining pods stay reachable for existing sessions until they naturally end.
- Load balancing — distribute new sessions across servers with cookie-based server affinity.
- Pod-level routing — Doors system requests (
/~/*) reach the exact pod that owns the session, even mid-rollout. - Invalid token handling — system requests with unrecognised tokens
receive
410 Gone. The Doors client triggers a full reload, landing on the fresh deployment. - Static Caddy config — hosts resolve to fresh deployments automatically; no config changes needed during a rollout.
- Geo-IP redirects — visitor IP matched against auto-updating country
IP databases; GET requests from configured countries redirect to the
matching domain. System paths (
/~/*) and non-GET requests pass through. Passes through if no match or database not ready.
Architecture
This package provides three Caddy modules:
doors_handler(http.handlers.doors_handler) — HTTP middleware that decrypts pod-IP tokens and stores upstreams in the request context.doors_upstreams(http.reverse_proxy.upstreams.doors_upstreams) — Reverse proxy upstream source that reads upstreams from the request context.doors_geo(http.handlers.doors_geo) — Geo-IP middleware that looks up visitor country by IP and redirects (307) if configured. System paths (/~/*) and non-GET requests pass through unchanged.
doors_handler and doors_upstreams communicate through Caddy's request context
via common.SetUpstreams / common.GetUpstreams. They must appear in the right
order:
Request → doors_handler → sets upstreams in context → reverse_proxy + doors_upstreams → proxies to upstream
doors_geo runs independently — it needs no other Doors modules.
doors_handler carries all configuration (secret, cookie name, upstream blocks).
doors_upstreams is configuration‑free — it only reads what doors_handler
wrote.
Build
Build a custom Caddy binary with both modules using xcaddy:
xcaddy build --with github.com/doors-dev/doors-caddy/plugin
This imports the plugin/ package which registers doors_geo, doors_handler,
and doors_upstreams.
Secret
openssl rand -base64 32
The same AES key must be set in both the Doors app (SECRET env) and the
Caddy config (secret directive).
Configuration
Caddyfile
example.com {
doors_handler {
secret <base64-aes-key>
cookie_name upstream
upstream {
pod_cidr 10.0.0.0/24
host svc.namespace.svc.cluster.local
upstream_port 8080
}
}
reverse_proxy {
dynamic_upstreams doors_upstreams
}
}
secret— Base64-encoded AES key, shared with the Doors app (required).cookie_name— Name of the cookie carrying the encrypted pod IP (required when more than one upstream).upstreamblocks:pod_cidr— CIDR covering all pods on this server (required).host— DNS name resolving to the fresh deployment (required).upstream_port— Port the Doors app listens on (required).
For horizontal scaling, add more upstream blocks in doors_handler:
upstream {
pod_cidr 10.0.1.0/24
host svc-2.namespace.svc.cluster.local
upstream_port 8080
}
doors_upstreams inside reverse_proxy takes no arguments — it reads
upstreams from the request context populated by doors_handler.
JSON
{
"apps": {
"http": {
"servers": {
"example": {
"routes": [
{
"handle": [
{
"handler": "doors_handler",
"secret": "<base64-aes-key>",
"cookie_name": "upstream",
"upstreams": [
{
"cidr": "10.0.0.0/24",
"host": "svc.namespace.svc.cluster.local",
"port": 8080
}
]
},
{
"handler": "reverse_proxy",
"upstreams": {
"source": "doors_upstreams"
}
}
]
}
]
}
}
}
}
}
Doors integration
The Doors app encrypts its pod IP and passes the token as the server ID.
Doors then handles the rest automatically — system paths, session cookie
name, and the sticky upstream cookie.
import "github.com/doors-dev/doors-caddy"
func main() {
cipher, _ := doorscaddy.NewTokenCipher(os.Getenv("SECRET"))
token := cipher.Encode(netip.MustParseAddr(os.Getenv("POD_IP")))
app := doors.NewApp(page,
doors.WithID(token),
doors.WithConf(doors.Conf{
ServerIDCookieName: "upstream",
}),
)
server := &http.Server{Addr: ":8080", Handler: app}
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGTERM)
go func() {
<-sigCh
app.Drain(func() {
server.Shutdown(context.Background())
})
}()
server.ListenAndServe()
}
doors.WithID(token) wires everything:
- Doors system paths become
/~/<token>/...— caddy decrypts the token from the URL. - The session cookie is named
<token>. ServerIDCookieName: "upstream"sets an additional cookie with nameupstreamand value<token>. Caddy reads it for server affinity.
When app.Drain is called:
- Link navigation triggers a full browser reload instead of in-instance navigation. The browser loads the target URL fresh, which Caddy routes to the new deployment via host DNS.
- The
upstreamcookie is suppressed while draining, so no new sessions are pinned to the old server. - Existing sessions continue working — system requests still reach the draining pod by IP.
- The callback fires when the instance count reaches zero.
How it works
Per-server model
Each upstream block represents one server. host is a DNS name
(Kubernetes service) that resolves to the current fresh deployment.
pod_cidr covers all pods on that server — 1 fresh pod plus 0 or more
draining pods from past rollouts.
┌─────────────────────────────┐
│ 10.0.0.0/24 │
│ ┌─────────┐ ┌──────────┐ │
│ │ fresh │ │ draining │ │
│ │ 10.0.0.2│ │ 10.0.0.1 │ │
│ └─────────┘ └──────────┘ │
│ host: svc.ns.svc.local │
└─────────────────────────────┘
Request routing
System requests (/~/{token}/...). The token is an encrypted pod IP.
doors_handler decrypts it, matches the IP against upstream CIDRs, and
stores an upstream pointing to the pod IP directly, which
doors_upstreams then reads. This guarantees system calls always reach
the instance that owns the session — whether the pod is fresh or
draining. If the token fails to decrypt or matches no CIDR,
doors_handler returns 410 Gone and the Doors client performs a full
page reload.
Normal requests, single upstream. doors_handler always stores the
host-based upstream. doors_upstreams reads it and the reverse proxy
routes to the host, which resolves to the fresh deployment.
Normal requests, multiple upstreams. doors_handler reads the
upstream cookie (encrypted pod IP, set by Doors via
ServerIDCookieName). It matches the IP against upstream CIDRs to store
the matching server's host, keeping the session on the same server. No
cookie means a new session — all upstream hosts are stored and Caddy's
load-balancing policy selects one.
Rolling deployment
- Pod
10.0.0.1running on serversvc.ns.svc.local. Caddy routes all traffic there. - New pod
10.0.0.2deployed.svc.ns.svc.localDNS now resolves to the new pod. - Old pod receives SIGTERM, calls
app.Drain(...). Link clicks reload to the new pod. System requests still reach10.0.0.1by IP. - All sessions end. Drain callback fires. Pod terminates.
- Server back to one fresh pod.
Geo-IP redirects
doors_geo downloads regularly-updated IP-address-to-country databases in the
background and issues 307 (Temporary Redirect) responses when the visitor's
country matches a configured domain.
Database updates
A background goroutine periodically fetches a gzipped tar archive from ipverse/geo-ip-blocks (or a custom URL). The archive contains per-country JSON files with IPv4 and IPv6 prefixes. Both tables are populated and swapped in atomically on each successful download, so lookups never block.
- Update interval: 24 h by default, configurable with
update_interval. - Conditional requests:
ETagandIf-Modified-Sinceavoid re‑fetching unchanged data (HTTP 304). - Exponential backoff: on failure, retries start at 30 s and cap at 1 h, with jitter (±10 %) added to every wait.
- Download timeout: HTTP client timeout, 30 s default (
download_timeout). - Max body size: response body is capped at 32 MiB.
Request handling
On each request:
- System paths (
/~/*) and non-GET requests pass through immediately — no redirect, no country lookup. - Resolve the client IP (respects Caddy's
trusted_proxies). - Look up the IP in the current database to get an ISO 3166-1 alpha-2 country code.
- Look up the country code in the redirect map configured in the Caddyfile.
If found and the current host does not already match, issue a
307 Temporary Redirecttohttps://<target-domain>/<path>.
If the visitor is already on the target domain (the current host matches the redirect domain), no redirect is performed — the request passes through, preventing redirect loops. Similarly, if the database is not yet loaded, the country code is unknown, or there is no redirect configured for that country, the request passes through to the next handler unchanged.
Coverage
All country codes that should redirect must be explicitly listed. There is no catch-all redirect — visitors from uncovered countries stay on the current host (pass through). Country codes must not overlap across domains (behaviour is undefined). For correct routing every country you care about must appear in exactly one domain block.
Caddyfile
example.com {
doors_geo {
us.example.com {
AG AI AR AW BB BL BM BO BQ BR
BS BZ CA CK CL CO CR CU CW DM
DO EC FK GD GF GL GP GT GY HN
HT JM KN KY LC MF MH MQ MS MX
NI NU PA PE PF PM PR PY SR SV
SX TC TO TT US UY VC VE VG VI
WS
}
eu.example.com {
AD AE AF AL AM AO AQ AT AX AZ
BA BE BF BG BH BI BJ BY CD CF
CG CH CI CM CV CY CZ DE DJ DK
DZ EE EG ER ES ET EU FI FO FR
GA GB GE GG GH GI GM GN GQ GR
GW HR HU IE IL IM IQ IR IS IT
JE JO KE KG KM KW KZ LB LI LR
LS LT LU LV LY MA MC MD ME MG
MK ML MR MT MU MW MZ NA NE NG
NL NO OM PL PS PT QA RE RO RS
RU RW SA SC SD SE SI SK SL SN
SO SS ST SY SZ TD TG TJ TK TM
TN TR UA UG UZ VA WF YE YT ZA
ZM ZW
}
asia.example.com {
AS AU BD BN BT CN FJ FM GU HK
ID IN IO JP KH KI KP KR LA LK
MM MN MO MP MV MY NC NF NP NR
NZ PG PH PK PW SB SG TH TL TV
TW VN VU
}
}
}
Each key that is not a recognised directive (tarball_url,
update_interval, download_timeout) is treated as a domain block.
The values inside are
ISO 3166-1 alpha-2
country codes — one or more per line.
| Directive | Default | Description |
|---|---|---|
tarball_url |
https://github.com/ipverse/geo-ip-blocks/archive/refs/heads/master.tar.gz |
URL for the geo-ip-blocks tarball |
update_interval |
24h |
Interval between database downloads |
download_timeout |
30s |
HTTP client timeout for downloads |
<domain> |
(required — at least one) | Target domain; block contains country codes to redirect |
JSON
{
"handler": "doors_geo",
"update_interval": "24h",
"download_timeout": "30s",
"redirects": {
"us.example.com": [
"AG", "AI", "AR", "AW", "BB", "BL", "BM", "BO", "BQ", "BR",
"BS", "BZ", "CA", "CK", "CL", "CO", "CR", "CU", "CW", "DM",
"DO", "EC", "FK", "GD", "GF", "GL", "GP", "GT", "GY", "HN",
"HT", "JM", "KN", "KY", "LC", "MF", "MH", "MQ", "MS", "MX",
"NI", "NU", "PA", "PE", "PF", "PM", "PR", "PY", "SR", "SV",
"SX", "TC", "TO", "TT", "US", "UY", "VC", "VE", "VG", "VI",
"WS"
],
"eu.example.com": [
"AD", "AE", "AF", "AL", "AM", "AO", "AQ", "AT", "AX", "AZ",
"BA", "BE", "BF", "BG", "BH", "BI", "BJ", "BY", "CD", "CF",
"CG", "CH", "CI", "CM", "CV", "CY", "CZ", "DE", "DJ", "DK",
"DZ", "EE", "EG", "ER", "ES", "ET", "EU", "FI", "FO", "FR",
"GA", "GB", "GE", "GG", "GH", "GI", "GM", "GN", "GQ", "GR",
"GW", "HR", "HU", "IE", "IL", "IM", "IQ", "IR", "IS", "IT",
"JE", "JO", "KE", "KG", "KM", "KW", "KZ", "LB", "LI", "LR",
"LS", "LT", "LU", "LV", "LY", "MA", "MC", "MD", "ME", "MG",
"MK", "ML", "MR", "MT", "MU", "MW", "MZ", "NA", "NE", "NG",
"NL", "NO", "OM", "PL", "PS", "PT", "QA", "RE", "RO", "RS",
"RU", "RW", "SA", "SC", "SD", "SE", "SI", "SK", "SL", "SN",
"SO", "SS", "ST", "SY", "SZ", "TD", "TG", "TJ", "TK", "TM",
"TN", "TR", "UA", "UG", "UZ", "VA", "WF", "YE", "YT", "ZA",
"ZM", "ZW"
],
"asia.example.com": [
"AS", "AU", "BD", "BN", "BT", "CN", "FJ", "FM", "GU", "HK",
"ID", "IN", "IO", "JP", "KH", "KI", "KP", "KR", "LA", "LK",
"MM", "MN", "MO", "MP", "MV", "MY", "NC", "NF", "NP", "NR",
"NZ", "PG", "PH", "PK", "PW", "SB", "SG", "TH", "TL", "TV",
"TW", "VN", "VU"
]
}
}
JSON keys: tarball_url, update_interval, download_timeout,
redirects (object with string keys and arrays of two‑letter country codes).
Token format
- Plaintext: raw pod IP bytes (
netip.Addr.AsSlice) - Encryption: AES-GCM with random nonce, AAD
doors-pod-ip-v1 - Encoding: base64 raw-URL (no padding)
Modules
| Package | Role |
|---|---|
doorscaddy (root) |
Public API imported by Doors apps |
common/ |
Shared token cipher and request‑context helpers |
plugin/ |
Registration entry point for all three Caddy modules |
modules/handler/ |
Caddy module http.handlers.doors_handler |
modules/upstream/ |
Caddy module http.reverse_proxy.upstreams.doors_upstreams |
modules/geo/ |
Caddy module http.handlers.doors_geo |
License
Apache 2.0 — see LICENSE.
Documentation
¶
Index ¶
Constants ¶
This section is empty.
Variables ¶
var ErrorBase64 = lib.ErrorBase64
var ErrorCipher = lib.ErrorCipher
var ErrorParse = lib.ErrorParse
Functions ¶
This section is empty.
Types ¶
type TokenCipher ¶
type TokenCipher = lib.TokenCipher
func NewTokenCipher ¶
func NewTokenCipher(secret string) (TokenCipher, error)