Deploying to a VPS (Hetzner Cloud + Caddy)
This guide puts mailrelay-mcp-server on a small VPS behind a real domain, so MCP
clients can connect to a stable HTTPS URL without your workstation being involved.
Architecture:
MCP client ──HTTPS──▶ Caddy (:443, auto TLS) ──HTTP──▶ Node (127.0.0.1:3939)
on the VPS systemd service
The Node process never listens on a public interface — only Caddy does.
0. What this server does and doesn't hold
In HTTP mode the server is multi-tenant and holds no credentials of its own. Each user connects with their own Mailrelay account and API key, entered on the consent screen during the OAuth handshake and verified against Mailrelay before access is granted. There is no shared master token to deploy, rotate, or leak.
Those per-user credentials are stored encrypted (AES-256-GCM) under the service state directory, so restarts and deployments do not disconnect anyone. The encryption key lives in the same config file, which means this protects against disks, backups and snapshots leaving the machine — not against a root compromise of the host itself.
If any Mailrelay API key of yours has previously been pasted into a chat, a screenshot, or a shared terminal, issue a fresh one from Mailrelay → Settings → API and revoke the old one before using it here.
1. Create the server
In the Hetzner Cloud console:
- Type:
CAX11— 2 vCPU (Ampere Arm), 4 GB RAM, 40 GB SSD, 20 TB traffic. Far more than this workload needs. All runtime dependencies are pure JavaScript, so arm64 needs no special handling.CX23is the x86 equivalent if you prefer. - Image: Ubuntu 26.04 LTS (24.04 LTS works identically if you prefer a more settled release — the commands below are the same on both)
- Location: an EU region (Falkenstein / Nuremberg / Helsinki) — lowest latency from Spain, and keeps subscriber data in the EU.
- SSH key: add yours. Don't enable password login.
- Firewall: allow inbound
22,80,443only.
2. Point your domain at it
At your registrar (e.g. EuroDNS), for the subdomain you want to use:
| Type | Name | Value |
|---|---|---|
A |
mcp |
the server's IPv4 address |
AAAA |
mcp |
the server's IPv6 address |
Wait until dig +short mcp.yourdomain.com returns the right IP before step 6 —
Caddy's certificate request will fail if DNS hasn't propagated.
3. Base system setup
ssh root@YOUR_SERVER_IP
apt update && apt upgrade -y
Install Node.js 24 (the Active LTS line):
curl -fsSL https://deb.nodesource.com/setup_24.x | bash - && apt install -y nodejs git
Both third-party repos used here are distribution-agnostic — NodeSource publishes under
a nodistro suite and Caddy's under any-version — so neither depends on the Ubuntu
release codename.
Create an unprivileged user to run the service:
adduser --system --group --home /opt/mailrelay-mcp-server mcp
Harden SSH (recommended)
A public IP is scanned within hours; expect thousands of failed logins a day. Make sure none of them can succeed, and cut the noise:
Keys only. Put this in
/etc/ssh/sshd_config.d/01-hardening.conf(the01-prefix matters: sshd uses the first value it sees, so it must sort before other drop-ins):PasswordAuthentication no KbdInteractiveAuthentication no PermitRootLogin prohibit-password AllowUsers root X11Forwarding no MaxAuthTries 3 LoginGraceTime 30Changing the SSH port: on current Ubuntu,
Portinsshd_configdoes nothing. sshd is started byssh.socket(socket activation), and the socket decides what is listened on. Override it instead, in/etc/systemd/system/ssh.socket.d/override.conf:[Socket] ListenStream= ListenStream=0.0.0.0:YOUR_SSH_PORT ListenStream=[::]:YOUR_SSH_PORTThen
systemctl daemon-reload && systemctl restart ssh.socket, and open the port in the firewall. A new port is noise reduction, not security — the key-only rule above is what protects you.Don't lock yourself out. Add the new port alongside 22 first, confirm you can log in on it from a fresh connection, and only then remove 22 from the socket and from
ufw.systemd-run --on-active=300 <command that undoes it>is a cheap safety net: if you lose access, the change reverts itself; stop the timer once you've verified. Keep your provider's web console in mind as the last resort.fail2ban with
port = YOUR_SSH_PORTandbackend = systemdbans repeat offenders. Add your own IP toignoreipso you can't ban yourself.
4. Install the application
git clone https://github.com/YOUR_GITHUB_USERNAME/mailrelay-mcp-server.git /opt/mailrelay-mcp-server
cd /opt/mailrelay-mcp-server && npm ci --omit=dev && chown -R mcp:mcp /opt/mailrelay-mcp-server
5. Configure the service
Configuration goes in a root-owned file, not in a .env inside the app directory.
systemd reads it as root before dropping privileges, so the app user never needs access
to the file itself.
install -m 600 -o root -g root /dev/null /etc/mailrelay-mcp.env
Then edit /etc/mailrelay-mcp.env:
PUBLIC_URL=https://mcp.yourdomain.com
PORT=3939
SESSION_ENCRYPTION_KEY=<output of: openssl rand -hex 32>
No Mailrelay credentials belong here — in HTTP mode each user supplies their own during
the OAuth handshake. SESSION_ENCRYPTION_KEY encrypts the stored sessions; generate it
on the server so it never passes through a chat or a clipboard. Losing it is survivable:
the server starts with no sessions and users reconnect.
PUBLIC_URL must exactly match the URL clients connect to: it is the OAuth issuer
identifier, and a mismatch breaks the handshake.
6. Install Caddy and the service
apt install -y debian-keyring debian-archive-keyring apt-transport-https curl
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | tee /etc/apt/sources.list.d/caddy-stable.list && apt update && apt install -y caddy
Copy the provided config files, editing the domain in the Caddyfile first:
cp /opt/mailrelay-mcp-server/deploy/Caddyfile /etc/caddy/Caddyfile && nano /etc/caddy/Caddyfile
cp /opt/mailrelay-mcp-server/deploy/mailrelay-mcp.service /etc/systemd/system/
Start everything:
systemctl daemon-reload && systemctl enable --now mailrelay-mcp && systemctl reload caddy
7. Verify
Service is up and listening only on loopback:
systemctl status mailrelay-mcp --no-pager && ss -tlnp | grep 3939
TLS works and OAuth discovery is reachable:
curl -s https://mcp.yourdomain.com/.well-known/oauth-authorization-server | head -c 200
The MCP endpoint refuses unauthenticated calls (this should print 401):
curl -s -o /dev/null -w '%{http_code}\n' -X POST https://mcp.yourdomain.com/mcp
Live logs:
journalctl -u mailrelay-mcp -f
8. Connect a client
Any Mailrelay user connects the same way, with their own account:
Claude Desktop → Settings → Connectors → Add custom connector →
https://mcp.yourdomain.com/mcp → Connect. A consent screen asks for their Mailrelay
account URL and API key; those are checked against Mailrelay, and on success the
connector is authorized for that account only.
Claude Code:
claude mcp add --transport http mailrelay https://mcp.yourdomain.com/mcp
The client runs the same OAuth handshake and opens the consent screen in a browser. There is no static token to paste into a config file — access tokens are issued per user by the handshake.
9. Publishing the docs as a website (optional)
Caddy can serve the project docs as static pages on the same domain, with everything else still proxied to the MCP server. Generate them from the Markdown sources:
npm run build:site
That writes three pages into site/: index.html (from README.md),
deploy.html (this file) and license.html. Upload them:
scp site/*.html root@YOUR_SERVER_IP:/var/www/mailrelay-mcp/
(Add -P <port> if you moved SSH off port 22.)
The deploy/Caddyfile already contains the routing: a @docs matcher serves those
three paths from disk, the catch-all handle proxies to the MCP server, and
/DEPLOY.md and /LICENSE redirect to the rendered pages so repo-relative links
don't 404. Doc pages stay indexable; every API path gets
X-Robots-Tag: noindex, nofollow.
The sources keep repo-relative links (DEPLOY.md, LICENSE) so they resolve
correctly on GitHub; the build rewrites them to the published page URLs.
Re-run the build and upload whenever the Markdown changes — site/ is a build
artifact and is gitignored, not hand-maintained.
10. Updating
cd /opt/mailrelay-mcp-server && git pull && npm ci --omit=dev && systemctl restart mailrelay-mcp
A restart is required for code changes: Node loads the modules once at startup, so editing files on disk has no effect until the process restarts.
Operational notes
- You are custodian of other people's credentials while they're connected. An
issued token grants full access to that user's Mailrelay account, including
mailrelay_send_campaign, which sends real irreversible email. Encryption at rest protects disks, backups and snapshots that leave the machine; it does not protect against root on this box, which can read both the key and process memory. Treat root access here as equivalent to access to every connected account. - Restarts no longer disconnect anyone. Issued sessions are persisted encrypted in
$STATE_DIRECTORY/sessions.enc. Short-lived authorization codes and pending consents are not persisted, so a restart during a handshake means retrying that one connection. - Rotating
SESSION_ENCRYPTION_KEYinvalidates every stored session: the server logs that it could not decrypt the file, starts clean, and users reconnect. That is also the way to revoke everyone at once. - Revoking one user individually isn't supported yet. A user can revoke their own
access by rotating their API key in Mailrelay, which invalidates it here immediately.
To revoke everyone, rotate
SESSION_ENCRYPTION_KEYand restart. - Callers can never make the server read its own disk.
mailrelay_start_importtakes its CSV asfile_content_base64; thefile_pathoption exists only in local stdio mode and is absent from the HTTP server's tool schema. This matters because the service can readsessions.encand its own environment (which holdsSESSION_ENCRYPTION_KEY), and any holder of a Mailrelay account can connect. Don't add a tool that opens a caller-supplied path. - Request size. Bodies are capped at
MAX_REQUEST_BODY(default10mb) and read only after the caller is authenticated. Raise it if you expect larger imports; it also bounds memory use per request. - Toolsets. The server has 173 tools in eight toolsets; only
core(79 tools, about 14,000 tokens of the model's context) is always on. The consent screen offers the rest (SMS, WhatsApp, ecommerce, media, RSS…) as checkboxes and stores the choice in the user's session, so it survives restarts and is encrypted with the rest.MAILRELAY_TOOLSETSin/etc/mailrelay-mcp.envpre-ticks some for everyone; it is also the fallback for sessions issued before toolsets existed. To change a user's toolsets they reconnect. Don't enable everything by default: it more than doubles the context every conversation starts with. - Regression tests:
npm testruns offline, with no credentials, and includes the conformance test that checks every tool against Mailrelay's API definition and the filesystem rule above.npm run test:liveneeds a real account and covers authentication, token binding, session persistence, toolset selection and read-only calls against the real API. Run both before deploying.