A Caddy module that routes requests to Podman containers by domain with scale-to-zero. Idle containers are checkpointed automatically (via CRIU) and restored on the next incoming request, fully freeing memory while preserving process state.
Client -> Caddy + relight_podman (:443) -> Podman container (bridge IP)
|
+-- checkpoints idle containers / restores on request
Supports two deployment modes:
- Multi-node - multiple VPS nodes behind a Cloudflare Worker that handles routing. Each node sends heartbeat stats to the Worker.
- Standalone - single VPS, Caddy handles TLS directly with on-demand certificates.
Containers are discovered by label prefix (default: relight):
# Basic - single domain
podman run -d \
--label relight.domain=myapp.com \
--label relight.port=3000 \
myapp-image
# Multiple domains (comma-separated)
podman run -d \
--label relight.domain=myapp.com,www.myapp.com \
myapp-imagerelight.domain- required, domain(s) to route to this containerrelight.port- optional, overrides module's defaultapp_port
xcaddy build --with github.com/getrelight/caddy-relight-podman=./
./caddy run --config CaddyfileRequires Podman with the socket enabled:
systemctl start podman.socket{
order relight_podman before reverse_proxy
}
:443 {
tls /etc/caddy/origin-cert.pem /etc/caddy/origin-key.pem
relight_podman {
idle_timeout 5m
wake_timeout 5s
app_port 8080
watch_interval 30s
heartbeat_url https://router.example.workers.dev/heartbeat
heartbeat_secret {env.HEARTBEAT_SECRET}
node_ip {env.NODE_IP}
}
reverse_proxy {http.vars.relight_upstream}
}{
order relight_podman before reverse_proxy
on_demand_tls {
ask http://127.0.0.1:5555/check
}
}
https:// {
tls {
on_demand
}
relight_podman {
idle_timeout 5m
wake_timeout 5s
app_port 8080
ask_listen 127.0.0.1:5555
}
reverse_proxy {http.vars.relight_upstream}
}| Directive | Default | Description |
|---|---|---|
podman_socket |
/run/podman/podman.sock |
Path to the Podman Unix socket |
label_prefix |
relight |
Label prefix for container discovery |
idle_timeout |
5m |
How long before an idle container is checkpointed (min 30s) |
wake_timeout |
5s |
Max time to wait for a container to restore |
app_port |
8080 |
Default port on the container to proxy to |
watch_interval |
30s |
How often to check for idle containers |
heartbeat_url |
(disabled) | CF Worker URL for heartbeat stats |
heartbeat_secret |
Bearer token for heartbeat auth | |
node_ip |
This node's public IP (for heartbeat) | |
ask_listen |
(disabled) | Address for on-demand TLS validation server |
On each request the module:
- Extracts the hostname from the request
- Lists Podman containers with
relight.domainlabel and finds a match:- First tries exact match (label contains the full hostname, e.g.
myapp.com) - Falls back to first subdomain label (e.g.
myappfrommyapp.apps.example.com)
- First tries exact match (label contains the full hostname, e.g.
- If the container is checkpointed (exited), calls CRIU restore via Podman and blocks until ready
- Sets
{http.vars.relight_upstream}toip:portfor Caddy'sreverse_proxy - Records the request time for idle tracking
A background goroutine runs every watch_interval and checkpoints containers that haven't received traffic for idle_timeout. Checkpointing saves full process state to disk via CRIU and frees all container memory.
Concurrent requests to a checkpointed container are coalesced - only one restore call is made, all requests block on the same wake signal.
When heartbeat_url is configured, the watcher also collects per-container CPU and memory stats and POSTs them to the CF Worker:
{
"node_ip": "167.99.12.34",
"containers": [
{"domain": "myapp.com", "status": "running", "cpu_pct": 45.2, "mem_mb": 512},
{"domain": "other.com", "status": "checkpointed", "cpu_pct": 0, "mem_mb": 0}
],
"total_cpu_pct": 45.2,
"total_mem_mb": 512
}See image/ for a Packer template that builds a DO snapshot with Podman, Caddy + this module, and cloud-init setup.
cd image
packer build packer.pkr.hclThe resulting image auto-detects the droplet's public IP and configures Caddy on first boot via cloud-init user-data.