Skip to content

Reverse Proxy

Caddy serves as the single entry point for all traffic. It terminates TLS and routes requests to the appropriate backend service based on hostname and path.

Why Caddy?

Caddy was chosen over nginx or Traefik for several reasons:

  • Automatic TLS — No certbot, no cron jobs, no manual renewal scripts. Caddy handles Let's Encrypt natively.
  • Simple configuration — The Caddyfile is readable, concise, and easy to audit.
  • Single binary — No plugins required for the features lnkify uses.
  • HTTP/2 and HTTP/3 — HTTP/2 is on by default; HTTP/3 is enabled via experimental_http3 in the global servers block.

Caddyfile Walkthrough

The Caddyfile is located at Caddyfile in the repository root. It is mounted into the caddy container at /etc/caddy/Caddyfile.

Global Options

caddyfile
{
	admin off

	servers {
		protocol {
			experimental_http3
		}
	}

	on_demand_tls {
		ask http://server:4000/internal/tls-check {
			header_up X-Tls-Check-Secret {env.CADDY_TLS_CHECK_SECRET}
		}
	}

	request_body {
		max_size 5MB
	}
}

admin off disables Caddy's admin API, which is not needed in production and reduces the attack surface.

servers { protocol { experimental_http3 } } enables HTTP/3 (QUIC) support. The UDP port mapping for 443 in docker-compose (443:443/udp) is required for HTTP/3 to work.

on_demand_tls enables automatic HTTPS for custom domains. When a request arrives for a hostname Caddy hasn't seen before, Caddy asks the server's /internal/tls-check endpoint whether the domain is verified, and only then obtains a certificate. The X-Tls-Check-Secret header carries a shared secret (CADDY_TLS_CHECK_SECRET env var) so the server can authenticate the check came from Caddy. This prevents certificate abuse by unverified domains.

request_body { max_size 5MB } limits incoming request body size to 5 MB globally, protecting backend services from oversized payloads.

Security Headers Snippet

caddyfile
(security_headers) {
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
		X-Frame-Options "DENY"
		X-Content-Type-Options "nosniff"
		Referrer-Policy "strict-origin-when-cross-origin"
		Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self'; frame-src 'none'; object-src 'none'; base-uri 'self'; form-action 'self'"
	}
}

This reusable snippet is imported by every site block via import security_headers. It sets HSTS, framing protection, content-type sniffing prevention, referrer policy, and a strict Content-Security-Policy.

HTTP→HTTPS Redirect

caddyfile
:80, :443 {
	redir https://{host}{uri} permanent
}

All plain HTTP traffic on ports 80 and 443 receives a permanent redirect to HTTPS, preserving the hostname and full path. Matching both ports ensures that any request lacking TLS (including those to port 443 without a valid certificate) is redirected.

mcp.lnkify.io Block

caddyfile
mcp.lnkify.io {
	import security_headers

	rewrite * /mcp{uri}

	reverse_proxy server:4000 {
		header_up Host {host}
		header_up X-Real-IP {remote_host}
		header_up X-Forwarded-For {remote_host}
		header_up X-Forwarded-Proto {scheme}
		flush_interval -1
	}
}
  • import security_headers applies the shared security headers snippet (HSTS, CSP, X-Frame-Options, etc.).
  • All requests to mcp.lnkify.io are rewritten to the /mcp path using Caddy's rewrite directive with {uri} to preserve the full path and query string. For example, / becomes /mcp/ and /anything becomes /mcp/anything.
  • Proxied to the server service on port 4000.
  • flush_interval -1 disables response buffering — critical for MCP's Streamable HTTP transport, which relies on streaming JSON responses.
  • Client headers are explicitly forwarded via header_up directives so the server sees the original hostname, client IP, and protocol. Authorization, x-api-key, and Mcp-Session-Id are passed through automatically by Caddy.

app.lnkify.io Block (Dashboard SPA)

caddyfile
app.lnkify.io {
	import security_headers

	@api {
		path /graphql*
	}

	reverse_proxy @api server:4000 {
		header_up Host {host}
		header_up X-Real-IP {remote_host}
		header_up X-Forwarded-For {remote_host}
		header_up X-Forwarded-Proto {scheme}
	}

	reverse_proxy app:8080 {
		header_up Host {host}
		header_up X-Real-IP {remote_host}
		header_up X-Forwarded-For {remote_host}
		header_up X-Forwarded-Proto {scheme}
	}
}

The dashboard SPA lives on app.lnkify.io. Caddy evaluates named matchers before catch-all directives:

  1. @api — Requests matching /graphql* are proxied to the server on port 4000 for GraphQL API calls (same-origin, so no CORS needed).
  2. Catch-all — All other requests go to the app nginx container on port 8080, serving the React dashboard SPA.

lnkify.io Block (API + Slug Redirects)

caddyfile
lnkify.io {
	import security_headers

	@api {
		path /graphql*
		path /llms.txt
		path /llms-full.txt
	}

	reverse_proxy @api server:4000 {
		header_up Host {host}
		header_up X-Real-IP {remote_host}
		header_up X-Forwarded-For {remote_host}
		header_up X-Forwarded-Proto {scheme}
	}

	reverse_proxy server:4000 {
		header_up Host {host}
		header_up X-Real-IP {remote_host}
		header_up X-Forwarded-For {remote_host}
		header_up X-Forwarded-Proto {scheme}
	}
}

The GraphQL API, LLM discovery, and shortlink redirects live on lnkify.io. Caddy evaluates in order, with named matchers taking priority over the catch-all:

  1. @api — GraphQL API and LLM discovery endpoints (/llms.txt, /llms-full.txt) are proxied to the server on port 4000.
  2. Catch-all — Any remaining path is treated as a shortlink slug and proxied to the server. The server looks up the slug in the database and issues a 302 redirect.

docs.lnkify.io Block

caddyfile
docs.lnkify.io {
	import security_headers

	reverse_proxy docs:8080 {
		header_up Host {host}
		header_up X-Forwarded-Proto {scheme}
	}
}

Simple reverse proxy to the docs nginx container on port 8080. No path routing needed — everything goes to the docs service.

Custom Domains (On-Demand TLS)

caddyfile
:443, :80 {
	import security_headers

	tls {
		on_demand
	}

	reverse_proxy server:4000 {
		header_up Host {host}
		header_up X-Real-IP {remote_host}
		header_up X-Forwarded-For {remote_host}
		header_up X-Forwarded-Proto {scheme}
	}
}

When a user adds and verifies a custom domain, requests for that domain arrive at this catch-all :443, :80 block. The tls { on_demand } directive works with the global on_demand_tls to obtain certificates only for verified domains (checked via the /internal/tls-check endpoint). All traffic is proxied to the server, which resolves the slug+hostname to the correct target URL.

Header Forwarding

Each reverse_proxy block in the Caddyfile explicitly forwards these headers via header_up directives:

HeaderPurpose
HostOriginal hostname from the request
X-Real-IPClient's real IP address
X-Forwarded-ForClient's real IP address (standard proxy chain header)
X-Forwarded-ProtoOriginal protocol (http or https)

Without explicit header_up directives, backends would see Caddy's internal IP for all requests. Authorization, x-api-key, Mcp-Session-Id and other client headers are passed through automatically by Caddy.

TRUST_PROXY

Set TRUST_PROXY=1 in your .env file (this is the default). This tells Express to trust the first proxy hop (Caddy). The value is the number of trusted proxy hops — 1 for the single Caddy hop is correct and secure, as it prevents clients from spoofing IPs by prepending their own X-Forwarded-For entries. Only raise this if you add more trusted proxies in front of Caddy.

Port Mapping

Only the caddy service publishes ports to the host:

yaml
caddy:
  ports:
    - "80:80"
    - "443:443"
    - "443:443/udp"   # HTTP/3 (QUIC)

Ports 80 and 443 on your host are mapped to ports 80 and 443 in the Caddy container. All other services communicate over the internal Docker network and are not exposed to the outside world.

MCP Streaming Configuration

The MCP server uses Streamable HTTP transport. Response buffering in the reverse proxy must be disabled to allow the server to stream responses to the client. The flush_interval -1 directive in Caddy accomplishes this.

Without this setting, MCP clients would see timeouts or incomplete responses because Caddy would buffer the server's output before forwarding it to the client.

Security Considerations

  • Only Caddy is exposed to the internet. The server, database, and frontend containers are on the internal Docker network.
  • admin off prevents unauthorized access to Caddy's configuration API.
  • All HTTP traffic is redirected to HTTPS by default.
  • Caddy's automatic TLS uses modern cipher suites and protocol versions (TLS 1.2 minimum, TLS 1.3 preferred).

Next: Database

Released under the MIT License.