Every deployment article so far bound the server to 127.0.0.1 over plain HTTP.
That is correct for local use and unusable for anything else: a client on another
machine cannot reach loopback, and sending bearer tokens or OAuth codes over plain
HTTP leaks them. The fix is a TLS reverse proxy. This article puts
Caddy in front of a FastMCP
server so it is reachable over HTTPS, with certificates Caddy obtains and
renews on its own.
Caddy is the right tool for this on a Mac: it terminates TLS, proxies to the
FastMCP process on loopback, and handles certificates automatically, a local one
for development and a real Let’s Encrypt one for a public domain. You keep the
FastMCP server exactly as it is, listening on plain HTTP on 127.0.0.1, and let
Caddy own the public, encrypted edge.
The shape of the deployment
client ──HTTPS──▶ Caddy (:443) ──HTTP──▶ FastMCP (127.0.0.1:8000)
terminates TLS never exposed to the network
Two processes, one job each. FastMCP serves tools on loopback; Caddy owns the
certificate and the public port. Because FastMCP binds to 127.0.0.1, its
unencrypted port is unreachable from the network, so the only way in is through
Caddy’s HTTPS.
What you will build
- A FastMCP server that listens on plain HTTP on loopback.
- A local-dev
Caddyfilethat serves HTTPS on port 8443 using Caddy’s internal CA (no domain, no sudo, no public certificate). - A one-line production
Caddyfilefor a real domain with automatic Let’s Encrypt certificates. - A
pytestsuite that tests the tools and validates the Caddyfile, plusmaketargets to run the backend, the proxy, and an HTTPS smoke test.
Prerequisites
- macOS 13+ with Homebrew (brew.sh).
- uv 0.5+ —
brew install uv; verifyuv --version. - Caddy —
brew install caddy; verifycaddy version. - Xcode Command Line Tools (
xcode-select --install) formake. - For the production step: a domain name whose DNS points at your machine, and reachable ports 80 and 443. The local-dev step needs none of that.
Everything runs through uv, make, and caddy.
Step 1: Scaffold and lock down hygiene
Create the workspace and a .gitignore first.
Create the files
mkdir -p tls-caddy-fastmcp-server-macos
cd tls-caddy-fastmcp-server-macos
touch .gitignore
Add the code: .gitignore
__pycache__/
*.py[cod]
.venv/
.uv/
.pytest_cache/
.ruff_cache/
.mypy_cache/
logs/
*.log
*.pid
# Caddy runtime state
.caddy/
caddy_data/
.DS_Store
Detailed breakdown
- Standard
uv/macOS hygiene, plus Caddy’s local state directories in case you point Caddy’s data path into the project.
Step 2: Initialize the project with uv
Create the files
uv init --name macmcp --no-workspace
rm -f main.py hello.py
uv add fastmcp
uv add --dev pytest pytest-asyncio
Add the code: pyproject.toml
[project]
name = "macmcp"
version = "0.1.0"
description = "A FastMCP server behind a Caddy TLS reverse proxy"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"fastmcp>=3.4.3",
]
[dependency-groups]
dev = [
"pytest>=9.1.1",
"pytest-asyncio>=1.4.0",
]
Detailed breakdown
- Only FastMCP is a runtime dependency. Caddy is a separate binary installed with Homebrew, not a Python package.
Step 3: The FastMCP backend (plain HTTP on loopback)
The server never touches TLS. It listens on 127.0.0.1 over HTTP, and Caddy
handles everything public. Keeping it on loopback is a security property, not just
a default: the plain port is not reachable from other machines.
Create the file
touch server.py
Add the code: server.py
"""A plain FastMCP HTTP server, meant to sit behind a TLS reverse proxy.
The server binds to 127.0.0.1 and speaks plain HTTP. It never terminates TLS
itself; Caddy does that in front of it and forwards requests over the loopback
interface. Binding to loopback means the unencrypted port is not reachable from
the network — only Caddy's HTTPS port is.
"""
import os
from fastmcp import FastMCP
mcp = FastMCP("macmcp")
@mcp.tool
def word_count(text: str) -> int:
"""Count the whitespace-separated words in a block of text."""
return len(text.split())
@mcp.tool
def slugify(text: str) -> str:
"""Turn a title into a URL-safe, lowercase slug."""
cleaned = [c.lower() if c.isalnum() else "-" for c in text.strip()]
slug = "".join(cleaned)
while "--" in slug:
slug = slug.replace("--", "-")
return slug.strip("-")
def main() -> None:
# Always HTTP on loopback; TLS is Caddy's job.
mcp.run(
transport="http",
host=os.environ.get("MCP_HOST", "127.0.0.1"),
port=int(os.environ.get("MCP_PORT", "8000")),
)
if __name__ == "__main__":
main()
Detailed breakdown
host="127.0.0.1"binds to loopback only. Even on a networked machine, the plain HTTP port is not exposed, so no unencrypted traffic can reach it from outside.- No transport switch here. Earlier articles toggled stdio vs HTTP; this one is
always HTTP because its whole purpose is to sit behind Caddy. Auth, if you add
it, works the same behind the proxy since Caddy forwards the
Authorizationheader unchanged.
Step 4: The local-development Caddyfile
For development you want HTTPS without a domain or a public certificate. Caddy’s
internal CA issues a local certificate, and setting a non-privileged HTTPS
port means no sudo.
Create the file
touch Caddyfile
Add the code: Caddyfile
# Caddyfile — local development
#
# Serves HTTPS on port 8443 using Caddy's internal CA (no domain, no sudo, no
# public certificate). Reverse-proxies to the FastMCP server on loopback.
# Run with: caddy run --config Caddyfile
#
# The MCP endpoint is then https://localhost:8443/mcp/
{
# Use a non-privileged HTTPS port and Caddy's internal CA for local certs.
https_port 8443
local_certs
}
localhost {
reverse_proxy 127.0.0.1:8000
}
Detailed breakdown
- The global block (
{ ... }) setshttps_port 8443, so Caddy serves HTTPS on 8443 rather than 443 and does not need root.local_certstells Caddy to issue certificates from its internal CA for every site, which is exactly what you want off a real domain. localhost { reverse_proxy 127.0.0.1:8000 }is the whole proxy: requests tohttps://localhost:8443are decrypted and forwarded to the FastMCP backend on127.0.0.1:8000. Caddy passes through the path, so the MCP endpoint ishttps://localhost:8443/mcp/.- Caddy’s config is intentionally tiny. It handles connection pooling, HTTP/2, header forwarding, and certificate management with these few lines.
Step 5: The production Caddyfile
Reaching the server from other machines means a real domain and a publicly trusted certificate. With Caddy that is one line, and the certificate is obtained and renewed automatically.
Create the file
touch Caddyfile.production
Add the code: Caddyfile.production
# Caddyfile.production — public HTTPS with automatic certificates
#
# Replace mcp.example.com with your real domain. Requirements:
# - a DNS A/AAAA record for the domain pointing at this machine
# - ports 80 and 443 reachable from the internet
# Caddy then obtains and renews a real Let's Encrypt certificate automatically —
# no cron, no certbot, no manual renewal.
#
# Run it (port 443 needs privileges):
# sudo caddy run --config Caddyfile.production
# or install it as a background service (copy this to /opt/homebrew/etc/Caddyfile):
# brew services start caddy
mcp.example.com {
reverse_proxy 127.0.0.1:8000
}
Detailed breakdown
mcp.example.com { reverse_proxy 127.0.0.1:8000 }is the entire production config. Because the site address is a real domain, Caddy automatically requests a Let’s Encrypt certificate over the ACME protocol, serves HTTPS on 443, and renews before expiry. There is no certbot, no cron job, and no renewal script.- DNS and ports are the only prerequisites. The domain’s DNS record must point at the machine, and ports 80 (for the ACME challenge) and 443 must be reachable. Caddy also redirects HTTP to HTTPS by default.
- Running on 443 needs privileges. Use
sudo caddy run, or install Caddy as a service withbrew services start caddyreading/opt/homebrew/etc/Caddyfile.
Step 6: Test the tools and validate the Caddyfile
Test the server’s tools in-memory, and assert the Caddyfile is syntactically valid so a broken config fails in CI rather than at deploy time.
Create the files
mkdir -p tests
touch tests/__init__.py tests/test_server.py pytest.ini
Add the code: pytest.ini
[pytest]
asyncio_mode = auto
Add the code: tests/test_server.py
"""Exercise the server's tools in-memory (transport-independent)."""
import subprocess
from pathlib import Path
from shutil import which
import pytest
from fastmcp import Client
from server import mcp
@pytest.mark.asyncio
async def test_word_count():
async with Client(mcp) as client:
result = await client.call_tool("word_count", {"text": "one two three"})
assert result.data == 3
@pytest.mark.asyncio
async def test_slugify():
async with Client(mcp) as client:
result = await client.call_tool("slugify", {"text": "Served over HTTPS"})
assert result.data == "served-over-https"
@pytest.mark.skipif(which("caddy") is None, reason="caddy not installed")
def test_caddyfile_is_valid():
"""The committed Caddyfile must pass `caddy validate`."""
caddyfile = Path(__file__).resolve().parent.parent / "Caddyfile"
proc = subprocess.run(
["caddy", "validate", "--config", str(caddyfile)],
capture_output=True, text=True,
)
assert proc.returncode == 0, proc.stderr
Detailed breakdown
- The tool tests use FastMCP’s in-memory client, independent of transport, so they pass with nothing running.
test_caddyfile_is_validshells out tocaddy validate. It is skipped whencaddyis not installed (so CI without Caddy still passes), and otherwise catches a typo in the Caddyfile before you try to deploy it.
Run them:
uv run pytest -v
Step 7: The Makefile
Wrap the backend, the proxy, validation, and an HTTPS smoke test. Plain make
prints help.
Create the file
touch Makefile
Add the code: Makefile
.DEFAULT_GOAL := help
# --- Deploy configuration (launchd user agent for the FastMCP backend) --------
LABEL := com.macmcp.server
PLIST := $(HOME)/Library/LaunchAgents/$(LABEL).plist
UV := $(shell command -v uv)
DIR := $(shell pwd)
DOMAIN := gui/$(shell id -u)
.PHONY: help install serve proxy validate trust test smoke deploy undeploy status logs clean
help: ## Show this help screen
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \
| awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-10s\033[0m %s\n", $$1, $$2}'
install: ## Sync runtime and dev dependencies
uv sync
serve: ## Run the FastMCP backend on loopback HTTP (Ctrl-C to stop)
MCP_TRANSPORT=http uv run python server.py
proxy: ## Run Caddy in front (HTTPS on 8443 -> 127.0.0.1:8000)
caddy run --config Caddyfile
validate: ## Check the Caddyfile is valid
caddy validate --config Caddyfile
trust: ## Install Caddy's local CA so clients trust the dev certificate
caddy trust
test: ## Run the test suite
uv run pytest -v
smoke: ## Call a tool over HTTPS through the proxy (skips CA verify for local)
@MCP_VERIFY=false uv run python scripts/smoke.py
deploy: ## Run the FastMCP backend as a launchd service (loopback)
@mkdir -p logs
@sed -e 's#@UV@#$(UV)#g' \
-e 's#@DIR@#$(DIR)#g' \
-e 's#@PATH@#$(dir $(UV)):/usr/bin:/bin#g' \
deploy/$(LABEL).plist.template > $(PLIST)
launchctl bootout $(DOMAIN) $(PLIST) 2>/dev/null || true
launchctl bootstrap $(DOMAIN) $(PLIST)
@echo "Backend deployed. Run Caddy in front with 'make proxy' (dev) or as a service (prod)."
undeploy: ## Stop the backend service and remove its plist
launchctl bootout $(DOMAIN) $(PLIST) 2>/dev/null || true
rm -f $(PLIST)
@echo "Removed $(LABEL)."
status: ## Show whether the backend service is running
@launchctl print $(DOMAIN)/$(LABEL) 2>/dev/null \
| grep -E 'state =|pid =' || echo "$(LABEL) is not loaded."
logs: ## Tail the backend log files
@tail -n 40 -f logs/server.out.log logs/server.err.log
clean: ## Remove caches and logs
rm -rf .pytest_cache logs __pycache__ tests/__pycache__
Detailed breakdown
serveandproxyrun in two terminals during development: the backend on loopback, and Caddy in front of it.validatechecks the Caddyfile, andtrustinstalls Caddy’s local CA so clients trust the dev certificate.deployruns the FastMCP backend as alaunchdservice, matching the companion deploy article. Caddy runs separately, either withmake proxyfor development or as a Homebrew service in production.
Step 8: The HTTPS smoke client
A tiny client that calls a tool over HTTPS through Caddy, so you can confirm the whole path end to end.
Create the files
mkdir -p scripts deploy
touch scripts/smoke.py deploy/com.macmcp.server.plist.template
Add the code: scripts/smoke.py
"""Call the server over HTTPS through the Caddy reverse proxy.
By default it connects to the local dev proxy at https://localhost:8443/mcp/.
For local testing with Caddy's internal CA, set MCP_VERIFY=false to skip
certificate verification (equivalent to `curl -k`). In production, run
`caddy trust` (or use a real public certificate) and leave verification on.
"""
import asyncio
import os
from fastmcp import Client
URL = os.environ.get("MCP_URL", "https://localhost:8443/mcp/")
VERIFY = os.environ.get("MCP_VERIFY", "true").lower() != "false"
async def main() -> None:
async with Client(URL, verify=VERIFY) as client:
tools = [t.name for t in await client.list_tools()]
print("tools:", tools)
result = await client.call_tool("slugify", {"text": "Served over HTTPS"})
print("slugify ->", result.data)
if __name__ == "__main__":
asyncio.run(main())
Add the code: deploy/com.macmcp.server.plist.template
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.macmcp.server</string>
<key>ProgramArguments</key>
<array>
<string>@UV@</string>
<string>run</string>
<string>python</string>
<string>server.py</string>
</array>
<key>WorkingDirectory</key>
<string>@DIR@</string>
<key>EnvironmentVariables</key>
<dict>
<key>MCP_TRANSPORT</key>
<string>http</string>
<key>MCP_HOST</key>
<string>127.0.0.1</string>
<key>MCP_PORT</key>
<string>8000</string>
<key>PATH</key>
<string>@PATH@</string>
</dict>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>@DIR@/logs/server.out.log</string>
<key>StandardErrorPath</key>
<string>@DIR@/logs/server.err.log</string>
</dict>
</plist>
Detailed breakdown
Client(URL, verify=VERIFY)connects over HTTPS.verifycontrols TLS certificate checking;MCP_VERIFY=falseskips it, which is the client-side equivalent ofcurl -kand the right choice against Caddy’s untrusted local CA. Aftercaddy trust, or with a real certificate, leave verification on.- The plist runs only the FastMCP backend on loopback. Caddy is deployed separately, so the two concerns stay independent.
Step 9: Run the whole path
Start the backend and the proxy, then call a tool over HTTPS.
# Terminal 1 — the FastMCP backend on loopback
make serve
# Terminal 2 — Caddy in front, HTTPS on 8443
make proxy
Confirm the certificate and the round-trip:
# Terminal 3
curl -sk https://localhost:8443/mcp/ -X POST \
-H 'Accept: application/json, text/event-stream' \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' -o /dev/null -w "%{http_code}\n"
# 307 (the MCP endpoint, reached over TLS)
make smoke
# tools: ['word_count', 'slugify']
# slugify -> served-over-https
To drop the -k / MCP_VERIFY=false locally, install Caddy’s CA once:
make trust # adds Caddy's local CA to the system trust store
For production, point DNS at the machine, use Caddyfile.production with your
domain, run Caddy on 443, and clients connect to https://mcp.example.com/mcp/
with a normal, publicly trusted certificate.
Troubleshooting
bind: permission deniedon port 443. Only root may bind 443. Usesudo caddy run, install Caddy as a service, or use the devCaddyfileon 8443.- The client reports a certificate error. In development Caddy’s CA is
untrusted; run
make trust, or setMCP_VERIFY=falsefor testing. In production this means the domain or certificate is misconfigured. - Caddy cannot get a public certificate. The domain’s DNS must resolve to the
machine and ports 80 and 443 must be reachable from the internet. Check with
curl -v http://your-domainfrom another network. - 502 Bad Gateway from Caddy. The FastMCP backend is not running on
127.0.0.1:8000. Start it withmake serve(ormake deploy) and confirm withcurl http://127.0.0.1:8000/mcp/. - The plain HTTP port is reachable from the network. It should not be; confirm
the backend binds
127.0.0.1, not0.0.0.0. Only Caddy should be exposed.
Recap
You put a FastMCP server behind HTTPS without changing the server:
- The FastMCP backend stays on plain HTTP on loopback, so its unencrypted port is never exposed to the network.
- Caddy terminates TLS and reverse-proxies to it, issuing a local certificate for development and a real Let’s Encrypt certificate for a public domain, with automatic renewal.
- The Caddyfile is a few lines, validated in the test suite, and switching from local to production is a one-line change.
uv,make,caddy, andlaunchdrun and deploy the two processes.
Next improvements
- Run Caddy as a managed service (
brew services start caddy) so it starts at boot alongside the launchd backend. - Add security headers and HTTP/3 in the Caddyfile (
headerand theserversglobal option). - Combine with the auth articles: Caddy handles TLS while FastMCP verifies license keys, OAuth, or JWTs behind it.
- Front several backends from one Caddy instance, routing by path or subdomain.