Architecture
Deep technical overview of how Hatch's DNS, TLS, proxy, daemon, and tunnel systems work.
Last updated: April 18, 2026
Architecture
Hatch is a single daemon that orchestrates five subsystems: DNS resolution, TLS certificate management, HTTPS reverse proxying, session management, and a management API. This page explains each one.
Architecture overview
Browser request: https://myapp.test
|
v
+-------------------+
| macOS Resolver | /etc/resolver/test -> 127.0.0.1:5053
+--------+----------+
v
+-------------------+
| DNS Server | *.test -> 127.0.0.1
+--------+----------+
v
+-------------------+
| Caddy (HTTPS) | TLS termination + reverse proxy
| port 443 | cert signed by Hatch CA
+--------+----------+
v
+-------------------+
| Your Dev Server | http://localhost:3000
+-------------------+
DNS
Hatch runs a DNS server on 127.0.0.1:5053 using the miekg/dns library.
Resolver file: During hatch init, a file is created at /etc/resolver/<tld> containing:
nameserver 127.0.0.1
port 5053
macOS checks /etc/resolver/ for per-domain nameserver overrides. Any DNS query for *.test is sent to 127.0.0.1:5053.
Query handling:
- Queries matching
*.<tld>return127.0.0.1(A record) or::1(AAAA record) - All other queries are forwarded to the system’s upstream DNS servers
TLS certificates
Hatch maintains a two-tier CA hierarchy. See HTTPS and Certificates for the user-facing explanation.
Root CA
- ECDSA P-256 key pair, generated during
hatch init - Self-signed, valid for 10 years
- Stored at
~/.hatch/certs/rootCA.pemand~/.hatch/certs/rootCA-key.pem - Added to macOS Keychain as a trusted certificate via
security add-trusted-cert
Intermediate CA
- ECDSA P-256, signed by the root CA
- Valid for 10 years
- Stored at
~/.hatch/certs/intermediateCA.pemand~/.hatch/certs/intermediateCA-key.pem - Not added to Keychain; trust chains through the root
Site certificates
- Generated on-the-fly by Caddy’s built-in PKI module
- Signed by the intermediate CA
- Cached in
~/.hatch/caddy/ - Created automatically when a new domain is first accessed
Reverse proxy
Hatch embeds Caddy as a library. Caddy handles TLS termination and reverse proxying.
Config translation: Hatch translates its YAML config into Caddy’s JSON config format. Each service becomes a Caddy route:
- Host matching — routes are matched by domain (e.g.,
myapp.test) - Path matching — services with a
routefield match a path prefix (e.g.,/api/*) - Subdomain matching — services with a
subdomainfield match<sub>.<domain> - Proxy headers — all upstreams receive
X-Forwarded-Proto: httpsso frameworks generate correct URLs and set secure cookies - WebSocket support — services with
websocket: truegetConnection/Upgradeheader forwarding and instant response flushing
Routes are sorted by specificity: subdomains first, then path routes, then catch-all.
Error pages: When an upstream returns 502 or 503, Caddy intercepts the response and serves a branded HTML page showing the failing domain and upstream URL. A catch-all fallback route handles requests that don’t match any configured domain with a 404 page.
HTTP -> HTTPS redirect: All HTTP requests are redirected to HTTPS with a 302.
Live reload: When config.yml changes, Hatch re-translates the config and loads it into Caddy via its admin API (localhost:2019). No process restart needed.
Daemon
The daemon is a single long-running process managed by macOS launchd.
Launchd
A plist file is installed at ~/Library/LaunchAgents/dev.hatch.daemon.plist:
| Property | Value |
|---|---|
| Label | dev.hatch.daemon |
| Program | Path to hatch binary |
| Arguments | hatch _run (hidden command) |
| RunAtLoad | true if auto_start enabled |
| KeepAlive | true if auto_start enabled |
hatch up installs and loads the plist. hatch down unloads it and stops the tray app.
Tray process
The system tray app runs as a separate process (hatch _tray). hatch up spawns it automatically when tray_icon is enabled. The tray communicates with the daemon over the local HTTP API at 127.0.0.1:42824. A file lock at ~/.hatch/tray.lock prevents duplicate tray instances and stores the tray PID so that hatch down can send SIGTERM to stop it.
Startup sequence
- Acquire PID lock (
~/.hatch/hatch.pid) - Clean up orphaned processes from a previous daemon instance
- Load and validate config
- Verify ports are available
- Start DNS server
- Start session manager (restore sessions from state file)
- Start Caddy with translated config (including session routes)
- Start health checker
- Start process manager (supervised commands)
- Start tunnel manager (Cloudflare Tunnels)
- Start API server (
127.0.0.1:42824) - Start config file watcher
- Start session idle ticker (checks every 60 seconds)
- Block until shutdown signal
Config watching
The daemon watches ~/.hatch/config.yml for changes using filesystem notifications. When a change is detected:
- Reload and validate the config
- Reconcile sessions (destroy sessions whose parent project was disabled or removed)
- Merge session synthetic projects into config
- Re-translate to Caddy JSON
- Push to Caddy via admin API
- Update health checker targets
- Reconcile managed processes
- Reconcile tunnels (start new, stop removed)
No daemon restart is required for config changes.
A config reload also triggers automatically when a named tunnel finishes connecting. This ensures Caddy learns about the tunnel’s external domains even if the Cloudflare API returned empty results at startup. Multiple tunnel starts within 3 seconds are coalesced into a single reload.
API server
The daemon exposes a local HTTP API on 127.0.0.1:42824 for the CLI and dashboard.
| Method | Path | Description |
|---|---|---|
| GET | /api/status | Daemon info (PID, uptime, version) |
| GET | /api/projects | List all projects |
| POST | /api/projects | Add a project |
| PUT | /api/projects/{name} | Update a project |
| DELETE | /api/projects/{name} | Remove a project |
| PATCH | /api/projects/{name}/toggle | Toggle enabled state |
| GET | /api/health | Service health statuses |
| GET | /api/logs | Live log stream (SSE) |
| GET | /api/settings | Read global settings (JSON) |
| PUT | /api/settings | Write global settings (JSON) |
| GET | /api/config | Read config (YAML) |
| PUT | /api/config | Write config (YAML) |
| GET | /api/certs | Certificate status |
| GET | /api/processes | Process statuses |
| GET | /api/tunnels | Tunnel statuses |
| POST | /api/tunnels/{project}/{service}/start | Start a tunnel |
| POST | /api/tunnels/{project}/{service}/stop | Stop a tunnel |
| POST | /api/restart | Reload config |
| POST | /api/sessions | Create a session |
| GET | /api/sessions | List sessions |
| GET | /api/sessions/{project}/{name} | Get session details |
| DELETE | /api/sessions/{project}/{name} | Destroy a session |
The API is localhost-only.
Tray API
The tray process serves additional endpoints for daemon lifecycle control:
| Method | Path | Description |
|---|---|---|
| POST | /api/tray/start | Start the daemon via launchd |
| POST | /api/tray/stop | Stop the daemon via launchd |
| POST | /api/tray/restart | Restart the daemon via launchd |
Sessions
Sessions are ephemeral project instances with dynamically allocated ports and unique subdomains. See Sessions for the user-facing explanation.
Synthetic config: When sessions exist, the session manager generates synthetic project entries and merges them into the config before Caddy translation. Each session service gets a proxy pointing to its allocated port and a command with {{port}} substituted. This means Caddy, the health checker, and the process manager all handle session services through the existing pipeline with zero code changes.
Port allocation: The port allocator scans the ephemeral range (49152-65535 by default) for free TCP ports using net.Listen probes. Ports used by static project proxy URLs are excluded. Allocated ports are tracked in memory and released when a session is destroyed.
Idle tracking: A background ticker runs every 60 seconds and checks each session’s LastActive timestamp against its TTL. Sessions that exceed their TTL are destroyed. The LastActive timestamp is updated when HTTP traffic hits the session’s subdomain (via Caddy access log parsing or API activity).
State persistence: Running sessions are written to ~/.hatch/sessions.json on every create/destroy. On daemon restart, sessions are restored from this file. If previously allocated ports are taken, new ones are allocated and service commands are re-templated.
Limits: A maximum of 20 concurrent sessions is enforced. Session names are validated as DNS hostname labels.
MCP server
The hatch mcp command starts a Model Context Protocol server using stdio transport. AI coding tools (Claude Code, Cursor, etc.) connect to it for programmatic session and project management.
Configuration:
{
"mcpServers": {
"hatch": {
"command": "hatch",
"args": ["mcp"]
}
}
}
Architecture: The MCP server is a thin client that translates MCP tool calls into HTTP requests to the daemon REST API at 127.0.0.1:42824. It does not manage state directly.
Tools: create_session, stop_session, list_sessions, list_projects, restart_service, get_daemon_status.
Tunnels
Hatch runs cloudflared as a subprocess, one per active tunnel. See Tunnels for the user-facing explanation.
Quick tunnels use cloudflared tunnel --url <upstream>. Hatch scans stdout for a trycloudflare.com URL and waits up to 30 seconds.
Named tunnels use cloudflared tunnel run <name>. When a cloudflare_token is configured, Hatch resolves the tunnel JWT by calling three Cloudflare API endpoints: (1) /accounts to find the account ID, (2) /accounts/{id}/cfd_tunnel?name=... to find the tunnel UUID, (3) /accounts/{id}/cfd_tunnel/{tunnel_id}/token to get the JWT. The JWT is passed via the TUNNEL_TOKEN environment variable (never in process arguments).
Rewrite proxy: Quick tunnels route through a local rewrite proxy between cloudflared and the upstream. The proxy: (1) strips absolute localhost URLs from HTML responses for PNA compliance, (2) discovers secondary dev servers and retries 404’d requests against them, (3) retries failed WebSocket upgrades against the dev server for HMR support. Named tunnels skip the proxy.
Binary discovery: exec.LookPath with fallbacks to /opt/homebrew/bin/cloudflared and /usr/local/bin/cloudflared for launchd environments.
Lifecycle: Tunnels start in background goroutines on daemon boot or config reload. When a named tunnel finishes starting, it fires an OnTunnelReady callback that triggers a debounced config reload. This re-resolves the tunnel’s external domains from the Cloudflare API and rebuilds the Caddy config so requests route correctly. If a tunnel process exits unexpectedly, it stays stopped until manually restarted or the config reloads.
State persistence: Running tunnel metadata is written to ~/.hatch/tunnels.json for CLI status display. Tunnels are not restored from this file on startup.
Health checking
Hatch periodically checks the health of each service’s upstream target. Health status is exposed via the API and displayed in the dashboard and hatch status output.
Process management
See Process Management for the user-facing explanation.
PID tracking: The process manager writes running process PIDs to ~/.hatch/processes.json. On daemon startup, Hatch reads this file and kills any surviving processes from a previous instance (SIGTERM with 5-second timeout, then SIGKILL). This prevents port conflicts after a daemon crash or upgrade.
File locations
| Path | Purpose |
|---|---|
~/.hatch/config.yml | Main configuration |
~/.hatch/certs/ | CA certificates and keys |
~/.hatch/logs/hatch.log | Daemon log file |
~/.hatch/hatch.pid | Daemon PID lock file |
~/.hatch/tray.lock | Tray instance lock file |
~/.hatch/processes.json | Managed process PID state file |
~/.hatch/tunnels.json | Active tunnel metadata |
~/.hatch/sessions.json | Session state (ports, TTL, timestamps) |
~/.hatch/caddy/ | Caddy data (cached site certs) |
/etc/resolver/<tld> | macOS DNS resolver override |
~/Library/LaunchAgents/dev.hatch.daemon.plist | Launchd service |