Every container on your home machine, reachable from your phone, anywhere.
Start a container on the laptop at home. On 5G, in a hotel, open https://immich.lab.example.com. Real certificate, nothing exposed to the internet, no configuration per service. Gantry watches the Docker socket, gives every container with a published port a name, and puts routes, containers, logs, a terminal and resource charts in one web UI. Turn on Tailscale access and the same names work from every device in your tailnet.
| Where you are | What you open |
|---|---|
| At the machine | http://immich.localhost |
| Anywhere else, on your tailnet | https://immich.lab.example.com |
Same container, same name, no extra step when you add the next one. The UI itself is https://lab.example.com, and its links follow the address you opened it on: *.localhost at the desk, *.lab.example.com on the phone.
Tailscale already connects your devices and MagicDNS already names them. What it lacks is wildcard records, so immich.<anything> cannot point at your laptop without a DNS server somewhere. Gantry ships that server, and nothing else:
- A dependency-free DNS responder (UDP A records, under 200 lines) answers
*.lab.example.comwith this host's Tailscale IP and refuses everything else. It binds only the Tailscale address. - Tailscale split DNS sends queries for that one domain to it. One entry in the admin console, once. Every other name keeps using your normal DNS.
- Caddy already listens on every interface, so it just matches every route on the second suffix as well.
- HTTPS is a Let's Encrypt wildcard for
*.lab.example.com, obtained over Cloudflare DNS-01 and renewed by Caddy in-process. Only your tailnet ever resolves the names; public DNS never learns them.
phone on 5G laptop at home
─────────── ──────────────
https://immich.lab.example.com
│
│ "immich.lab.example.com?" split DNS ┌─ Gantry DNS responder :53 on the Tailscale IP
├──────────────────────────────────────────▶ │ "100.x.y.z — this host"
│◀──────────────────────────────────────────┘
│
│ HTTPS, inside the WireGuard tunnel ┌─ Caddy :443 wildcard cert
└──────────────────────────────────────────▶ │ Host: immich.lab.example.com
└───▶ immich container :8096
No tunnel daemon, no port forwarding, no DNS entries to maintain, no labels on containers. Traffic is plain HTTP inside Tailscale's encrypted tunnel, HTTPS on top if you enable it; nothing listens on the public internet. If the host is asleep the names simply do not resolve.
| Gantry + Tailscale | tailscale serve |
Cloudflare Tunnel | Port forwarding | |
|---|---|---|---|---|
| A new container appears as | a name, automatically | one serve per port |
an ingress rule per service | a port + DDNS per service |
| The URL | https://immich.lab.example.com |
https://laptop.tailnet.ts.net:8443 |
https://immich.example.com |
https://home.example.com:8096 |
| Reachable from the internet | no, tailnet only | no, tailnet only | yes, public | yes, public |
| HTTPS | wildcard, auto-renewed | automatic | automatic | yours to set up |
| Extra moving parts | none | none | cloudflared |
router config |
| Dashboard, logs, terminal, charts | yes | no | no | no |
Coding agents run on the machine with the Docker socket; you are increasingly not at that machine. With Gantry, whatever the agent starts is a URL a second later. Kick off a Claude Code session on the desktop, walk away, and open https://timelog.lab.example.com on your phone from the train to see what it built. The bundled MCP server lets the agent deploy a compose stack, wait for Gantry to route it, and hand back the link itself.
- Auto-discovers containers. Start a container with a published port and
<name>.localhostroutes to it within a second. Stop it and the route stays visible as offline; start it again (even with a new container ID) and it comes back. - Manual routes for anything that is not a container: a hostname and a port.
- Reach it from your tailnet. Enable Tailscale access and every route also answers on
<name>.<your-domain>for any device in your tailnet. Gantry ships the DNS responder, so there is nothing else to run (see above). - Real HTTPS on the tailnet names. A Let's Encrypt wildcard via Cloudflare DNS-01, obtained and renewed by Caddy itself.
- Docker management. Start, stop and restart containers; live logs, a terminal, inspect output, per-container CPU / memory / network history; images, volumes, networks and the Docker event stream.
- Analytics. Proxy traffic per route and resource usage across containers, built to stay readable with dozens of them.
- Phone-friendly. The whole UI works on a phone in portrait or landscape.
- Zero config to start. One
docker compose up. No YAML per service, no labels.
Routes — every hostname, with the tailnet name alongside |
Analytics — pick the containers you want to compare |
Container — live stats, history, logs, inspect, terminal |
Logs — streamed, with auto-scroll |
Requirements:
- Docker with Compose, and access to
/var/run/docker.sock - Port 80 free (Caddy), plus 3001 (backend) and 2019 (Caddy admin API) on localhost
- Linux host. Gantry runs with
network_mode: hostso containers can be reached on the ports Docker publishes.
git clone https://github.com/awall451/gantry.git
cd gantry
docker compose up -d --buildOpen http://gantry.localhost. Every running container that publishes a port is already listed with a link.
*.localhost resolves to 127.0.0.1 in every modern browser without any DNS setup. Nothing in Gantry touches your system's resolver.
- The backend listens to Docker events (
start,die,destroy). - On every change it upserts routes in SQLite, rebuilds the complete Caddy configuration and pushes it through Caddy's admin API in one atomic load.
<container-name>.localhostproxies to the container's first published port. Names are sanitised to valid DNS labels.- Routes can be renamed, disabled or deleted on the Routes page; manual routes can be added on the Dashboard.
The Gantry UI itself is always gantry.<domain> and is the first route.
Off by default; a stock install never touches Tailscale. Turn it on under Settings → Tailscale and every route becomes reachable from any device in your tailnet.
| Where you are | What you open |
|---|---|
| On the machine running Gantry | http://<name>.localhost (unchanged) |
| Any other tailnet device | http://<name>.gantry.internal (or your own domain) |
| The Gantry UI from the tailnet | http://gantry.internal |
- Settings → Tailscale → Enable Tailscale access. Keep the default domain
gantry.internalor pick your own..internalis reserved for private use, so it can never collide with a public name. - Leave the Tailscale IP blank. Gantry detects the
tailscale0interface (or any100.64.0.0/10address) and shows what it found. Pin it only if the host has several. - Keep Answer DNS for the Tailscale domain on unless you already run a resolver for that zone. Gantry's responder binds UDP 53 on the Tailscale IP only.
- Save. The Status block should show the responder running, and the page prints the last step with your values filled in:
- Tailscale admin console → DNS → Nameservers → Add nameserver → Custom. Nameserver = this host's Tailscale IP. Turn on Restrict to domain and enter your domain. Save.
From a phone on the tailnet, open http://gantry.internal. The routes list now links to *.gantry.internal; on the laptop it keeps linking to *.localhost. Links follow the address you opened Gantry on, and both names are shown.
Browsers still flag plain http://, and some apps (PWAs, clipboard, camera) require a secure context. Settings → Tailscale → HTTPS gets a real certificate:
- Use a domain you own as the Tailscale domain, e.g.
lab.example.com, with its DNS hosted on Cloudflare. Only your tailnet ever resolves names under it; public DNS never learns them. - Create a Cloudflare API token scoped to that one zone with permission Zone → DNS → Edit.
- Put it in a
.envnext todocker-compose.yml(see.env.example):Caddy reads it directly for the ACME challenge. Gantry never stores it; the Settings page only reports whether it is set.CLOUDFLARE_API_TOKEN=... docker compose up -dso Caddy sees the variable, then enable HTTPS in Settings. Caddy requests*.lab.example.com+ the apex from Let's Encrypt over DNS-01 and renews in-process, with no restarts or cron.- Update the split-DNS entry in the Tailscale admin console to the new domain.
The Settings page shows the certificate currently served and its expiry, and warns when another process holds port 443 on the Tailscale address (a specific-address bind silently beats Caddy's wildcard bind; tailscale serve is the usual culprit).
Notes:
- The http→https redirect applies to the Tailscale domain only.
*.localhostis always plain http; browsers already treat it as a secure context. - TLDs such as
.devand.appare HTTPS-only in browsers (HSTS preload), so there is no plain-http fallback on them. If issuance ever breaks, switching the domain back to something likegantry.internalrestores plain http immediately.
Off by default, like everything else here. Turn it on before you enable tailnet access: without it, anyone who can reach Gantry can start, stop and exec into your containers.
scripts/set-password.sh # prompts for a username and password, writes .env
docker compose up -dThe password is hashed with scrypt; only the hash lands in .env, never the password. After that:
- Every page asks for the login once per browser. Sessions last 7 days (
GANTRY_SESSION_TTL_HOURS) and are stored server-side, so Log out in the sidebar really ends them. - Changing the password (run the script again) signs out every device.
- Five wrong passwords lock the login for five minutes.
- The cookie is
HttpOnlyandSameSite=Strict, andSecurewhen you are on the HTTPS tailnet name. - The MCP server needs a token once login is on:
scripts/set-password.sh --api-token, then rerunmcp/setup.sh. scripts/set-password.sh --disableremoves the login again.
Two protections are on for everyone, login or not. Gantry answers only on its own names (gantry.<domain>, the tailnet domain, *.localhost, IP addresses), which blocks DNS-rebinding attacks from web pages. And it refuses state-changing requests that come from another site, including a container app on a sibling subdomain. If you open Gantry on some other name, add it to GANTRY_ALLOWED_HOSTS.
A shell on the machine running Gantry, in the browser, from anywhere on your tailnet. It is off by default and only works with a login set.
scripts/set-password.sh # if you have not already
scripts/enable-host-shell.sh # run as the user the shell should belong to
docker compose up -dHost terminal then appears under System. Opening it asks for the Linux username it runs as and your password again, even when you are logged in. The page never shows the username, so someone who gets into Gantry still has to know it.
How it is fenced in:
- It is SSH to this machine's own sshd, as your user, not root.
sudoworks as usual, and sshd logs every session. - The script generates a dedicated key and never touches your own keys. In
authorized_keysit is restricted tofrom="127.0.0.1,::1"with forwarding off, so a copy of it is useless on any other machine. - The host's SSH key is pinned at setup. If it ever changes, Gantry refuses to connect.
- Re-typing the password buys a single-use ticket that expires after 60 seconds. It is bound to your browser session, and the MCP server's API token cannot get one.
- Optionally, it also needs a 6-digit code from an authenticator app (TOTP).
scripts/set-password.shasks whether you will use the host terminal and offers to set this up: it shows a QR code, and saves the secret only after you type a matching code. Codes are computed offline on the phone and the server, so this works entirely inside your tailnet. Each code works once.--totpsets it up later, and--disable-totpremoves it. - Sessions close after 30 minutes without input (
GANTRY_HOST_SHELL_IDLE_MINUTES). Opens, closes and wrong passwords are logged by the backend.
sshd must allow public-key login for your user. Password login can stay off. scripts/enable-host-shell.sh --disable removes the key and switches the feature off.
All settings live in the UI (Settings) and persist in SQLite. Defaults:
| Setting | Default | Meaning |
|---|---|---|
| Base domain | localhost |
Suffix for every route on this machine |
| Tailscale access | off | Also match routes on the Tailscale domain and run the DNS responder |
| Tailscale domain | gantry.internal |
Second suffix; the zone the responder answers |
| Tailscale IP | auto | This host's Tailscale IPv4; blank = detect |
| Answer DNS | on | Run the built-in responder |
| DNS port | 53 |
Tailscale split DNS only talks to 53 |
| HTTPS | off | :443 with a Let's Encrypt wildcard for the Tailscale domain |
| ACME email | empty | Optional; Let's Encrypt uses it for expiry warnings |
| Redirect http→https | on | On the Tailscale domain only |
Environment (set in docker-compose.yml):
| Variable | Default | Purpose |
|---|---|---|
PORT |
3001 |
Backend port |
CADDY_ADMIN |
http://localhost:2019 |
Caddy admin API |
DB_PATH |
/app/data/proxy.db |
SQLite file (mounted from ./data) |
LOG_PATH |
/logs/access.log |
Caddy access log, tailed for analytics |
CLOUDFLARE_API_TOKEN |
empty | From .env; only needed for HTTPS |
GANTRY_USERNAME |
admin |
Login username |
GANTRY_PASSWORD_HASH |
empty | scrypt hash from scripts/set-password.sh; empty = no login |
GANTRY_SESSION_TTL_HOURS |
168 |
How long a login lasts |
GANTRY_API_TOKEN |
empty | Bearer token for the MCP server once login is on (32+ characters) |
GANTRY_HOST_SHELL |
0 |
1 enables the host terminal (needs a login and scripts/enable-host-shell.sh) |
GANTRY_HOST_SHELL_USER |
empty | User the host shell logs in as; set by the script |
GANTRY_HOST_SHELL_PORT |
22 |
This machine's sshd port |
GANTRY_HOST_SHELL_IDLE_MINUTES |
30 |
Close a host shell after this long without input |
GANTRY_TOTP_SECRET |
empty | Authenticator (TOTP) secret; when set, opening the host terminal also needs a 6-digit code |
GANTRY_ALLOWED_HOSTS |
empty | Extra names Gantry may be opened on, comma-separated, *.suffix allowed, * disables the check |
Ports used on the host: 80 (and 443 with HTTPS) for Caddy, 2019 Caddy admin, 3001 backend, 53/udp on the Tailscale IP when the responder is on.
| Page | What you get |
|---|---|
| Dashboard | Every routed container with status, port, link and live CPU / memory; expand a row for details and actions |
| Routes | All auto and manual routes; rename, enable/disable, delete |
| Analytics | Container resources (CPU, memory, network) over a time range, and proxy traffic per route. Tap chips, cards or lines to choose which containers to compare; the selection is in the URL |
| Containers | Everything Docker knows about, routed or not; detail page with Overview, History, Logs, Inspect and Terminal tabs |
| Events | Docker start/stop/die/destroy events with exit codes |
| Images / Volumes / Networks | List and remove images and volumes; inspect networks |
| Settings | Base domain, Tailscale access, HTTPS, live status and the setup steps |
mcp/ contains a Model Context Protocol server that exposes Gantry to an MCP client such as Claude Code: list routes, add aliases, check a service, and deploy or update a repo's compose stack and verify that Gantry picked it up. See mcp/README.md.
./dev.sh # Caddy in Docker, backend + frontend with hot reload| Service | URL |
|---|---|
| UI (Vite HMR) | http://localhost:5173 |
| Backend API | http://localhost:3001/api |
| Proxy | http://*.localhost |
Or individually:
cd backend && PORT=3001 CADDY_ADMIN=http://localhost:2019 DB_PATH=../data/proxy.db LOG_PATH=../logs/access.log npm run dev
cd frontend && npm run dev -- --port 5173Frontend changes in production mode need docker compose up -d --build; the built assets are baked into the image.
cd backend && npm test # unit + Docker integration (disposable containers)
cd frontend && npm test # vitest
cd frontend && npm run e2e # Playwright: every page at phone + desktop sizes, against a running server (BASE_URL)See CONTRIBUTING.md for the TDD rules and test tiers.
browser ──► Caddy :80/:443 ──► containers (published ports)
│ ▲
gantry.* │ │ admin API :2019 (full config, atomic load)
▼ │
backend :3001 ── Docker socket (events, stats, exec, logs)
│ │ └───── SQLite (routes, settings, analytics)
│ └───────── UDP :53 DNS responder (Tailscale domain)
└── WebSocket ─► UI (routes:updated, container events, stats)
| Component | Tech |
|---|---|
| Reverse proxy | Caddy 2 (+ caddy-dns/cloudflare), configured through the admin API |
| Backend | Node.js, Express, dockerode, better-sqlite3, ws |
| Frontend | SvelteKit, Chart.js, xterm.js |
| Tests | vitest, supertest, Playwright |
All three services run with network_mode: host.
- Port 80 already in use. Another proxy or web server is listening. Stop it, or change Caddy's port in
backend/src/caddy-client.js(routes assume:80). - A container has no route. It publishes no port (
docker psshows none), or it is on another host. Add a manual route if you know the port. - Two containers, one name. Names are sanitised to DNS labels, so
my_appandmy-appcollide; the second one is skipped. Rename one. - DNS responder shows a bind error.
EACCES: the backend is not root (it is, in the shipped image).EADDRINUSE: something else holds53on the Tailscale IP.EADDRNOTAVAIL: no Tailscale IP was found; is Tailscale up? - Tailnet names resolve on the laptop but not the phone. Check the split-DNS entry: nameserver = the host's Tailscale IP, restricted to exactly the domain in Settings.
- HTTPS never gets a certificate. Token missing or wrong scope (
docker compose logs caddyshows the ACME error), or port 443 is held by another process (Settings warns about this). - Analytics is empty for a new route. The access log is aggregated a second or so after Caddy writes it.
- Frontend or API calls fail with CORS from a
*.localhostapp. The app hardcodeslocalhost:PORT; allow the*.localhostorigin in its CORS config.
Gantry controls your Docker daemon (start/stop, exec into containers, delete images and volumes), which is root-equivalent on the host. Login is optional and off by default: turn it on (see Login) before opening Gantry to your tailnet. Even with a login, keep it on localhost and your tailnet only; never publish port 80/443/3001 to the internet. The Cloudflare token is read by Caddy from .env and is never written to the database or shown in the UI.
Gantry is free and MIT. If it saves you time, buy me a coffee on Ko-fi. Part of every tip goes to carbon removal through Stripe Climate.
MIT.







