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_http3in the globalserversblock.
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
{
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
(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
: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
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_headersapplies the shared security headers snippet (HSTS, CSP, X-Frame-Options, etc.).- All requests to
mcp.lnkify.ioare rewritten to the/mcppath using Caddy'srewritedirective with{uri}to preserve the full path and query string. For example,/becomes/mcp/and/anythingbecomes/mcp/anything. - Proxied to the
serverservice on port 4000. flush_interval -1disables response buffering — critical for MCP's Streamable HTTP transport, which relies on streaming JSON responses.- Client headers are explicitly forwarded via
header_updirectives so the server sees the original hostname, client IP, and protocol.Authorization,x-api-key, andMcp-Session-Idare passed through automatically by Caddy.
app.lnkify.io Block (Dashboard SPA)
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:
@api— Requests matching/graphql*are proxied to the server on port 4000 for GraphQL API calls (same-origin, so no CORS needed).- Catch-all — All other requests go to the
appnginx container on port 8080, serving the React dashboard SPA.
lnkify.io Block (API + Slug Redirects)
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:
@api— GraphQL API and LLM discovery endpoints (/llms.txt,/llms-full.txt) are proxied to the server on port 4000.- 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
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)
: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:
| Header | Purpose |
|---|---|
Host | Original hostname from the request |
X-Real-IP | Client's real IP address |
X-Forwarded-For | Client's real IP address (standard proxy chain header) |
X-Forwarded-Proto | Original 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:
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 offprevents 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