On-demand TCP+UDP proxy for Docker containers.
🥳 Now with UDP support! 🎉
lazy-tcp-proxy allows you to run many Dockerized services on a single host, but only start containers when a connection arrives. It stops containers after a configurable idle timeout, saving resources while providing seamless access.
Supported architectures: linux/amd64, linux/arm64, linux/arm/v7
To save compute resources (CPU, RAM, Electricity) on a single host by keeping containers stopped until they're actually needed, making it practical to run many low-traffic services without paying the cost of having them all running simultaneously.
"Finally, scale to zero!" - Nick G.
"This is something that should really be built into Docker!" - Tom H.
The quickest way to get started is to use the docker-compose "recipes".
These have many common services, with preconfigured options, so you can pick and choose.
(Don't forget to run docker-compose.lazy-tcp-proxy.yml)
Otherwise you can always run the container from the command line. You will need to add labels to your managed containers (see below).
docker run -d \
-v /var/run/docker.sock:/var/run/docker.sock \
-e IDLE_TIMEOUT_SECS=30 \
-e POLL_INTERVAL_SECS=5 \
-p "8080:8080" \
-p "9000-9099:9000-9099" \
--restart=always \
--name lazy-tcp-proxy \
mountainpass/lazy-tcp-proxyAdd lazy-tcp-proxy labels to any container you want proxied. At minimum:
labels:
- "lazy-tcp-proxy.enabled=true"
- "lazy-tcp-proxy.ports=9000:80"Full label reference, including UDP, allow/block lists, health checks, webhooks, cron scheduling, and dependency cascade:
If you can't add labels to a container (e.g. it already exists and can't be recreated), or you want centralised configuration, use the YAML config file instead. YAML config takes full precedence over labels when both are present.
Configure via environment variables:
| Variable | Default | Description |
|---|---|---|
CONFIG_PATH |
/etc/lazy-tcp-proxy/config.yaml |
Path to the YAML config file |
ADMIN_PORT |
0 |
Admin API port (0 = disabled) |
ADMIN_API_KEY |
(required if ADMIN_PORT > 0) |
API key for the admin API (GET /config, GET /config/reload, PUT /config/update) |
| Variable | Description | Default |
|---|---|---|
IDLE_TIMEOUT_SECS |
How long (in seconds) a container must be idle before being stopped. 0 = stop immediately once all connections close |
120 |
START_TIMEOUT_SECS |
How long (in seconds) to wait for an upstream to be ready after a cold start — applies to the UDP datagram readiness probe, the HTTP health check (lazy-tcp-proxy.http-healthcheck), and the Docker HEALTHCHECK readiness gate. If the timeout is reached the connection/flow is dropped. Override per-container with the lazy-tcp-proxy.start-timeout-secs label |
30 |
POLL_INTERVAL_SECS |
How often (in seconds) to check for idle containers | 15 |
DOCKER_SOCK |
Path to Docker socket | /var/run/docker.sock |
WEB_PORT |
Port for the HTTP web server (dashboard, /metrics, /traefik, /portainer/templates, /portainer/git, /health); set to 0 to disable. STATUS_PORT is accepted as a legacy alias |
8080 |
WEB_HOST |
When set, exposes lazy-tcp-proxy's web endpoint via Traefik: adds Host('<WEB_HOST>') → http://<TRAEFIK_PROXY_HOST>:<WEB_PORT> to /traefik. Unset = no Traefik route for the web endpoint |
(none) |
STATUS_PORT |
Legacy alias for WEB_PORT; ignored when WEB_PORT is set |
8080 |
CONFIG_PATH |
Path to the dynamic YAML config file (see README_CONFIG.md) | /etc/lazy-tcp-proxy/config.yaml |
ADMIN_PORT |
Port for the admin API; set to 0 to disable (see README_CONFIG.md) |
0 (disabled) |
ADMIN_API_KEY |
API key for the admin API; required when ADMIN_PORT > 0 |
(none) |
LISTEN_START_PORT |
Starting port number for dynamically assigned listen ports (used when lazy-tcp-proxy.traefik-hosts or lazy-tcp-proxy.traefik-tcp-hosts labels are present without explicit ports). Ports already claimed by explicit lazy-tcp-proxy.ports / udp-ports labels are skipped |
8000 |
TRAEFIK_PROXY_HOST |
Hostname/IP Traefik uses to reach lazy-tcp-proxy's listen ports (used in /traefik service URLs) |
lazy-tcp-proxy |
TRAEFIK_ENTRYPOINT |
Traefik entry point added to every generated router; set to "" to omit |
websecure |
TRAEFIK_CERTRESOLVER |
Cert resolver added to every generated router's tls.certResolver; set to "" to omit |
myresolver |
TRAEFIK_DIAL_TIMEOUT |
Upstream dial timeout emitted in the lazy-transport ServersTransport in /traefik. Use Go duration syntax (e.g. 30s, 1m). Set to "" together with the other two timeout vars to suppress the ServersTransport entirely |
30s |
TRAEFIK_RESPONSE_HEADER_TIMEOUT |
Maximum time to wait for an upstream response header. Increase this for services that do long-running uploads (e.g. a Docker registry). Use Go duration syntax (e.g. 15m, 0 for no limit) |
15m |
TRAEFIK_IDLE_CONN_TIMEOUT |
Maximum time an idle keep-alive connection remains open to the upstream. Use Go duration syntax (e.g. 90s) |
90s |
COMPOSE_DIR |
Directory scanned for compose files and image archives when re-provisioning a missing container (see Compose Re-provisioning) | <dir of CONFIG_PATH>/compose |
RECIPES_DIR |
Directory of Docker Compose recipe files served by GET /portainer (see Portainer App Templates) |
<dir of CONFIG_PATH>/recipes |
METRICS_POSTGRES_URL |
PostgreSQL connection URL for metrics storage (see PostgreSQL Metrics). When set, per-port stats are accumulated and flushed to a proxy_metrics table every minute. When absent, metrics storage is disabled and no PostgreSQL connection is made |
(none — disabled) |
CORS_ALLOW_ORIGINS |
When set, adds Access-Control-Allow-Origin, Access-Control-Allow-Methods, and Access-Control-Allow-Headers headers to every response from the web server, and answers OPTIONS pre-flight requests with 204. Use * to allow all origins, or supply a specific origin such as https://my-dashboard.example.com. When unset no CORS headers are added |
(none — disabled) |
All are optional; defaults are safe for most setups.
The proxy exposes a lightweight HTTP server for operational visibility.
Returns a JSON object containing all currently managed containers and their state, plus process memory usage.
services is sorted alphabetically by container name (then by container ID as a tie-breaker).
last_active shows when a container last handled traffic (falling back to the proxy start time if it has never been used). last_active_relative shows the same information in human-readable form, making it easy to spot long-idle containers at a glance — handy for identifying decommissioning candidates.
container_missing is true when a config-only container has been removed from Docker (e.g. by docker system prune) but is still registered in the proxy. The status dashboard shows
memory_used is heap bytes currently in use; memory_total is total bytes mapped from the OS.
curl http://localhost:8080/metrics{
"memory_total": 14688256,
"memory_used": 3421872,
"services": [
{
"container_id": "b2c3d4e5f6a1",
"container_name": "idle-service",
"listen_port": 9001,
"target_port": 8080,
"running": false,
"container_missing": false,
"active_conns": 0,
"last_active": "2026-04-01T08:00:00Z",
"last_active_relative": "3 days ago"
},
{
"container_id": "a1b2c3d4e5f6",
"container_name": "my-service",
"listen_port": 9000,
"target_port": 80,
"running": true,
"container_missing": false,
"active_conns": 1,
"last_active": "2026-04-01T12:34:56Z",
"last_active_relative": "8 hours ago"
}
]
}Minimal liveness probe — always returns 200 ok while the proxy is running.
curl http://localhost:8080/health
# okGET /portainer serves all Docker Compose recipe files in RECIPES_DIR as a Portainer App Templates v2 JSON payload. This lets you deploy any recipe directly from the Portainer UI without copying files manually.
- Place your
*.ymlDocker Compose recipe files inRECIPES_DIR(default:/etc/lazy-tcp-proxy/recipes/). - In Portainer: Settings → App Templates → URL → set to:
http://<proxy-host>:8080/portainer/templates
On the next page load, all recipes appear in the App Templates list. Each template shows its configurable environment variables (detected automatically from ${VAR:-default} patterns in the YAML) as editable fields in the Portainer deploy wizard. Portainer clones the recipe files via the built-in git endpoint at /portainer/git.
| Variable | Default | Description |
|---|---|---|
RECIPES_DIR |
<dir of CONFIG_PATH>/recipes |
Directory containing *.yml recipe files to serve |
The directory is created automatically on startup if it does not exist.
| Endpoint | Description |
|---|---|
GET /portainer/templates |
Portainer App Templates v2 JSON — set this as the App Templates URL in Portainer |
GET /portainer/git |
Read-only git Smart HTTP endpoint — Portainer clones recipe files from here |
You can also clone the recipes directly:
git clone http://<proxy-host>:8080/portainer/gitcurl http://localhost:8080/portainer/templates{
"version": "2",
"templates": [
{
"type": 3,
"title": "docker-compose.postgres.5432",
"repository": {
"url": "http://localhost:8080/portainer/git",
"stackfile": "docker-compose.postgres.5432.yml"
},
"env": [
{ "name": "POSTGRES_USER", "label": "POSTGRES_USER", "default": "admin" },
{ "name": "POSTGRES_PASSWORD", "label": "POSTGRES_PASSWORD", "default": "password" },
{ "name": "POSTGRES_DB", "label": "POSTGRES_DB", "default": "postgres" }
]
}
]
}Both endpoints are public (no authentication required). If RECIPES_DIR is missing or empty, /portainer/templates returns an empty list and /portainer/git serves an empty repository.
Set METRICS_POSTGRES_URL to a PostgreSQL connection URL to enable persistent metrics storage:
METRICS_POSTGRES_URL=postgres://user:password@localhost:5432/mydbWhen set, the proxy:
- logs
metrics: enabled (host=… db=…)at startup (credentials are never logged) - creates a
proxy_metricstable automatically if it does not exist - accumulates per-port stats in memory and flushes a rollup row every minute
When absent, the proxy logs metrics: disabled (METRICS_POSTGRES_URL not set) and no PostgreSQL connection is made.
To verify the connection URL is correct before starting the proxy:
psql "postgres://user:password@localhost:5432/mydb"
# with SSL:
psql "postgres://user:password@host/dbname?sslmode=require"| Column | Type | Description |
|---|---|---|
rollup_at |
TIMESTAMPTZ |
Start of the 1-minute window this row covers |
container_name |
TEXT |
Name of the managed container |
port |
INTEGER |
Proxy listen port |
is_udp |
BOOLEAN |
true for UDP flows, false for TCP connections |
availability |
TEXT |
Container availability mode (always, cron, manual) |
connections_started |
BIGINT |
New inbound connections/flows in the window |
connections_ended |
BIGINT |
Closed connections/flows in the window |
connections_active |
INTEGER |
Live connections at the end of the window |
connections_peak |
INTEGER |
Peak concurrent connections during the window |
connections_failed |
BIGINT |
Connections that could not be established |
bytes_sent |
BIGINT |
Bytes forwarded from client → upstream |
bytes_received |
BIGINT |
Bytes forwarded from upstream → client |
request_duration_ms_avg/max/min |
DOUBLE PRECISION / BIGINT |
Connection lifetime stats (null if no connections ended) |
container_starts |
BIGINT |
Container start events in the window |
cold_start_ms_avg/max |
DOUBLE PRECISION / BIGINT |
Cold-start latency stats (null if no cold starts) |
container_stops |
BIGINT |
Container stop events in the window |
uptime_ms_total |
BIGINT |
Milliseconds the container was running during the window |
- Writes are non-blocking — a failed write never delays proxied traffic.
- Each write has a 15-second timeout.
- Up to 5 failed snapshots are retained in memory and retried on the next tick. When the buffer is full the oldest snapshot is dropped. Each retry is written as its own row with its original
rollup_attimestamp (no aggregation). - Windows are contiguous: the end of one window is the start of the next, so
uptime_ms_totalhas no gaps.
This should be core functionality in the docker engine. As such, I've raised a Feature Request to add this behaviour - docker/roadmap#899
docker system prune removes all stopped containers by default, not just unused images and build cache. Because lazy-tcp-proxy stops idle containers to save resources, your managed containers will almost certainly be stopped at the time you run the command — and will be permanently deleted.
Warning: Do not run
docker system prune(ordocker container prune) on a host runninglazy-tcp-proxyunless you intend to remove your managed containers.
If a config-only container (one registered via config.yaml rather than Docker labels) is removed this way, the proxy keeps its listener alive and waits for the container to be recreated. The status dashboard will show docker compose up (or equivalent) to recreate it — or let the proxy re-provision it automatically using Compose Re-provisioning.
To reclaim disk space without touching your (possibly stopped) managed containers, run the following commands individually instead of docker system prune:
# Remove dangling images
docker image prune
# Remove unused networks
docker network prune
# Remove unused volumes (optional, add if you want to clear volume space)
docker volume pruneThese clean up images, networks, and volumes without removing any containers — stopped or otherwise.
When a config-only container is missing and a connection arrives, the proxy can re-provision it automatically using a Docker Compose file you place in the compose directory (default: /etc/lazy-tcp-proxy/compose/).
For each service you want auto-provisioned, drop one or two files in the compose directory:
| File | Purpose |
|---|---|
<name>.yml or <name>.yaml |
Docker Compose file for the service |
<name>.tar.gz |
(optional) Custom image archive, loaded before compose up |
Example — for a service named minio:
/etc/lazy-tcp-proxy/compose/
minio.yml # required
minio.tar.gz # optional — for offline/custom images
When a connection arrives and the container is missing, the proxy:
- Looks for
<name>.yml(then<name>.yaml) in the compose directory. - If a matching
<name>.tar.gzarchive also exists, loads it into Docker first (equivalent ofdocker load -i minio.tar.gz). - Runs
docker compose up -dusing the compose file. - Once the container starts,
WatchEventsautomatically clears themissingflag and begins proxying traffic.
If no compose file is found, the proxy returns an error as before — no change in behaviour.
The compose file must specify container_name matching the registered service name so the proxy can find the container after it starts:
services:
minio:
image: minio/minio:latest
container_name: minio # must match the name in config.yaml
command: server /data --console-address ":9001"
volumes:
- minio-data:/data
volumes:
minio-data:Note: Do not add
lazy-tcp-proxy.enabled=truelabels to containers in these compose files. The proxy already manages them viaconfig.yaml; adding the label would create duplicate registrations.
| Variable | Default | Description |
|---|---|---|
COMPOSE_DIR |
<dir of CONFIG_PATH>/compose |
Directory scanned for compose files and image archives |
Mount the directory into the proxy container:
volumes:
- /path/to/compose-files:/etc/lazy-tcp-proxy/compose- Automatic TCP proxying: Listens on host ports and proxies to containers, starting them on demand.
- Label-based configuration: Opt-in containers using Docker labels—no static config files required. See README_LABELS.md.
- Dynamic YAML config: Override or supplement label configuration at runtime without recreating containers. See README_CONFIG.md.
- Admin API: Authenticated HTTP API (
GET /config,GET /config/reload,PUT /config/update) on a dedicated port. - Multi-port support: Proxy multiple TCP and/or UDP ports per container.
- Idle shutdown: Containers are stopped after a configurable period of inactivity.
- Dynamic discovery: Watches Docker events for new/removed containers and updates proxy targets live.
- Network auto-join: Proxy joins Docker networks as needed to reach containers by internal IP.
- Graceful shutdown: Leaves all joined networks on SIGINT/SIGTERM.
- Per-service IP filtering: Optional allow-list and block-list per container; supports plain IPs and CIDRs.
- UDP support: Forward UDP datagrams with per-client flow tracking and cold-start retry.
- Webhooks: POST lifecycle and connection events to a URL of your choice.
- Cron scheduling: Start and stop containers on a fixed schedule.
- Dependency cascade: Automatically start/stop related containers together.
- HTTP health check: Poll a URL after cold start before forwarding TCP traffic.
- Docker HEALTHCHECK gate: Automatically waits for containers with a
HEALTHCHECKto become healthy before forwarding. - Compose re-provisioning: Automatically recreates a missing container via a Docker Compose file when a connection arrives. Supports loading a custom image archive (
.tar.gz) before running compose up. - Portainer App Templates: Serves recipe files as a Portainer-compatible App Templates v2 endpoint (
GET /portainer), with auto-detected configurable environment variables. - Structured, colorized logs: Container names in yellow, network names in green, source addresses in cyan for easy scanning.
flowchart TD
A([Incoming TCP Connection<br/>on Host Port]) -->|External Port| B[`lazy-tcp-proxy` Docker Container]
B -->|Check target Container state| C{Target Container<br/> Running?}
C -- No --> D([Start Target Container])
C -- Yes --> E([Proxy Traffic])
D --> E
E -->|Internal Port/Network| F@{ shape: docs, label: "Target Docker Container/s"}
F -- Idle Timeout --> G([Stop Target Docker Container])
G -.->|Container Stopped| B
How it works:
- The proxy listens on host ports and intercepts incoming TCP connections.
- When a connection arrives, it checks if the target container is running (based on label or YAML configuration).
- If not running, it starts the container on demand.
- Proxies the connection to the container's internal port.
- If the container is idle for the configured timeout, it is stopped to save resources.
Services that are accessed infrequently and can tolerate a few seconds of startup latency on the first connection. Good examples:
- Home lab / self-hosted services — a Minecraft server, Gitea, Jellyfin, or a personal wiki that only a handful of people use occasionally
- Development environments — per-branch or per-developer services that sit idle most of the day
- Low-traffic internal tools — dashboards, admin panels, CI artefact browsers that are visited a few times a day
- Demo / staging environments — services that need to be reachable on-demand but don't justify running 24/7
cd lazy-tcp-proxy
VERSION=1.`date +%Y%m%d`.`git rev-parse --short=8 HEAD`
docker buildx build \
--platform linux/amd64,linux/arm64/v8 \
--tag mountainpass/lazy-tcp-proxy:${VERSION} \
--tag mountainpass/lazy-tcp-proxy:latest \
--push \
.The container is designed to run with an extremely low footprint.
CONTAINER ID NAME CPU % MEM USAGE / LIMIT MEM % NET I/O BLOCK I/O PIDS
cbc5f775a793 lazy-tcp-proxy 0.00% 4.238MiB / 19.52GiB 0.02% 1.51MB / 1.4MB 0B / 0B 13- Container names are shown in yellow:
\033[33m<name>\033[0m - Network names are shown in green:
\033[32m<name>\033[0m - All key events (startup, discovery, container start/stop, network join/leave, proxy activity) are logged with clear, structured messages.
- Rejection reasons for misconfigured containers are logged on every start event.
All changes are tracked as requirements in the requirements/ directory. See AGENTS.md for the full workflow. Every feature, fix, or change is documented and reviewed before implementation.
- Written in Go, using the official Docker Go SDK.
- Minimal Docker image (
FROM scratch). - See requirements/ for detailed design and implementation notes.
MIT