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> return 127.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.pem and ~/.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.pem and ~/.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 route field match a path prefix (e.g., /api/*)
  • Subdomain matching — services with a subdomain field match <sub>.<domain>
  • Proxy headers — all upstreams receive X-Forwarded-Proto: https so frameworks generate correct URLs and set secure cookies
  • WebSocket support — services with websocket: true get Connection/Upgrade header 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:

PropertyValue
Labeldev.hatch.daemon
ProgramPath to hatch binary
Argumentshatch _run (hidden command)
RunAtLoadtrue if auto_start enabled
KeepAlivetrue 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

  1. Acquire PID lock (~/.hatch/hatch.pid)
  2. Clean up orphaned processes from a previous daemon instance
  3. Load and validate config
  4. Verify ports are available
  5. Start DNS server
  6. Start session manager (restore sessions from state file)
  7. Start Caddy with translated config (including session routes)
  8. Start health checker
  9. Start process manager (supervised commands)
  10. Start tunnel manager (Cloudflare Tunnels)
  11. Start API server (127.0.0.1:42824)
  12. Start config file watcher
  13. Start session idle ticker (checks every 60 seconds)
  14. Block until shutdown signal

Config watching

The daemon watches ~/.hatch/config.yml for changes using filesystem notifications. When a change is detected:

  1. Reload and validate the config
  2. Reconcile sessions (destroy sessions whose parent project was disabled or removed)
  3. Merge session synthetic projects into config
  4. Re-translate to Caddy JSON
  5. Push to Caddy via admin API
  6. Update health checker targets
  7. Reconcile managed processes
  8. 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.

MethodPathDescription
GET/api/statusDaemon info (PID, uptime, version)
GET/api/projectsList all projects
POST/api/projectsAdd a project
PUT/api/projects/{name}Update a project
DELETE/api/projects/{name}Remove a project
PATCH/api/projects/{name}/toggleToggle enabled state
GET/api/healthService health statuses
GET/api/logsLive log stream (SSE)
GET/api/settingsRead global settings (JSON)
PUT/api/settingsWrite global settings (JSON)
GET/api/configRead config (YAML)
PUT/api/configWrite config (YAML)
GET/api/certsCertificate status
GET/api/processesProcess statuses
GET/api/tunnelsTunnel statuses
POST/api/tunnels/{project}/{service}/startStart a tunnel
POST/api/tunnels/{project}/{service}/stopStop a tunnel
POST/api/restartReload config
POST/api/sessionsCreate a session
GET/api/sessionsList 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:

MethodPathDescription
POST/api/tray/startStart the daemon via launchd
POST/api/tray/stopStop the daemon via launchd
POST/api/tray/restartRestart 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

PathPurpose
~/.hatch/config.ymlMain configuration
~/.hatch/certs/CA certificates and keys
~/.hatch/logs/hatch.logDaemon log file
~/.hatch/hatch.pidDaemon PID lock file
~/.hatch/tray.lockTray instance lock file
~/.hatch/processes.jsonManaged process PID state file
~/.hatch/tunnels.jsonActive tunnel metadata
~/.hatch/sessions.jsonSession state (ports, TTL, timestamps)
~/.hatch/caddy/Caddy data (cached site certs)
/etc/resolver/<tld>macOS DNS resolver override
~/Library/LaunchAgents/dev.hatch.daemon.plistLaunchd service
Edit on GitHub