Skip to content

Proxy (embedded Caddy)

Caddy runs in-process as a library. There is no Caddyfile. All config is JSON, built programmatically and pushed via caddy.Load(). Source: /internal/proxy/.

  1. NewCaddyProxy(cfg) allocates the proxy struct (no Caddy started yet).
  2. The reconciler calls ResolveRoutes(app, resolver) for each app and collects the routes. The resolver (DockerResolver in /internal/proxy/resolver.go) answers <service>:<port> upstreams by looking up the running container’s IP on the simpledeploy-public network via Docker API; host-port-backed endpoints skip the resolver entirely and resolve to localhost:<host_port>.
  3. The reconciler calls SetRoutes(routes) with the assembled table.
  4. SetRoutes validates every domain against ^[a-zA-Z0-9][a-zA-Z0-9.*-]*$, registers any per-domain rate-limit and IP-allowlist configs into the package-level registries, then calls reload().
  5. reload() runs buildConfig() to produce the full JSON, marshals it, and calls caddy.Load(data, true). Caddy hot-reloads in-place; in-flight requests complete on the old config, new requests use the new one.

Stop() calls caddy.Stop(). The admin endpoint is always disabled (admin.disabled: true); the only way to change config is via this code path.

Located in /internal/proxy/proxy.go, it returns a map[string]interface{} shaped like Caddy’s JSON schema.

For each Route:

  • One Caddy route entry with match.host set to the route’s domain.
  • A handler chain in this exact order: simpledeploy_ipaccess, simpledeploy_ratelimit, simpledeploy_metrics, then reverse_proxy to r.Upstream.
  • If TLS mode is custom, append a load-files entry pointing at <app_dir>/certs/<domain>.crt and .key with tags: [domain].

The whole thing is then assembled into a single HTTP server listening on the configured listenAddr (typically :443).

The tlsCfg block changes shape based on tlsMode:

ModeWhat buildConfig emits
auto (with email)tls.automation.policies[0].issuers[0] = {module: acme, email: <tlsEmail>}. ACME flow uses HTTP-01 challenge on :80.
localSame shape, issuer module is internal (Caddy’s local CA). Storage root is set to <dataDir>/caddy.
customNo automation policy. Caddy serves whatever was loaded via load_files.
offserver.automatic_https.disable: true. HTTP only.

Modes can mix per route: a single proxy can serve some routes with ACME, others with custom certs, others HTTP-only by setting simpledeploy.endpoints.N.tls.

All three are registered in init() of their respective files in /internal/proxy/. They are real Caddy modules (http.handlers.simpledeploy_*), not custom HTTP middleware bolted on the side. This means Caddy’s request lifecycle (logging, error handling, response recording) wraps them correctly.

/internal/proxy/reqmetrics.go. Wraps the response writer to capture status code, measures latency from before to after next.ServeHTTP, then non-blocking send into RequestStatsCh (a package-level chan<- set during startup). Dropped if full. Path is normalized via NormalizePath (numeric IDs and UUIDs become {id}) so the metrics table does not explode in cardinality.

/internal/proxy/ratelimit.go. Per-domain configs registered via RateLimiters.Set(domain, cfg) from SetRoutes. Each domain has its own domainLimiter keyed by client IP (default), header value, or query param depending on simpledeploy.ratelimit.by. Stale buckets are evicted lazily once the per-domain map exceeds 100 entries.

/internal/proxy/ipaccess.go. Allowlist of IPs and CIDRs from simpledeploy.access.allow. Validated as net.ParseIP or net.ParseCIDR before being added; invalid entries are logged and skipped.

When tlsMode = auto, Caddy obtains certs on first request to a new domain. The HTTP-01 challenge requires port 80 to be reachable from the public Internet for the apex of each registered domain. SimpleDeploy does not bind 80 directly; Caddy’s default HTTP server picks it up because Caddy’s automatic_https is enabled. Issued certs are stored under <dataDir>/caddy/ and reused across restarts.

Caddyfile is fine for static config but a poor fit when routes change at runtime in response to file events. JSON is Caddy’s native format, fully round-trippable, and caddy.Load() is faster than re-parsing a Caddyfile. It also means SimpleDeploy never has to escape user input into a DSL.

MockProxy in /internal/proxy/mock.go implements the same interface and just records the routes it was given, so tests can assert on the route table without starting Caddy.