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 Caddyfile that serves HTTPS on port 8443 using Caddy’s internal CA (no domain, no sudo, no public certificate).
  • A one-line production Caddyfile for a real domain with automatic Let’s Encrypt certificates.
  • A pytest suite that tests the tools and validates the Caddyfile, plus make targets to run the backend, the proxy, and an HTTPS smoke test.

Prerequisites

  • macOS 13+ with Homebrew (brew.sh).
  • uv 0.5+brew install uv; verify uv --version.
  • Caddybrew install caddy; verify caddy version.
  • Xcode Command Line Tools (xcode-select --install) for make.
  • 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 Authorization header 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 ({ ... }) sets https_port 8443, so Caddy serves HTTPS on 8443 rather than 443 and does not need root. local_certs tells 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 to https://localhost:8443 are decrypted and forwarded to the FastMCP backend on 127.0.0.1:8000. Caddy passes through the path, so the MCP endpoint is https://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 with brew services start caddy reading /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_valid shells out to caddy validate. It is skipped when caddy is 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

  • serve and proxy run in two terminals during development: the backend on loopback, and Caddy in front of it. validate checks the Caddyfile, and trust installs Caddy’s local CA so clients trust the dev certificate.
  • deploy runs the FastMCP backend as a launchd service, matching the companion deploy article. Caddy runs separately, either with make proxy for 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. verify controls TLS certificate checking; MCP_VERIFY=false skips it, which is the client-side equivalent of curl -k and the right choice against Caddy’s untrusted local CA. After caddy 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 denied on port 443. Only root may bind 443. Use sudo caddy run, install Caddy as a service, or use the dev Caddyfile on 8443.
  • The client reports a certificate error. In development Caddy’s CA is untrusted; run make trust, or set MCP_VERIFY=false for 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-domain from another network.
  • 502 Bad Gateway from Caddy. The FastMCP backend is not running on 127.0.0.1:8000. Start it with make serve (or make deploy) and confirm with curl 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, not 0.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, and launchd run 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 (header and the servers global 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.