Skip to content

Reverse proxy and TLS

How-to guide
For:
Server operators

A ReputeCast reverse proxy terminates TLS for your public URL and forwards every request to ReputeCast’s port 8080 with the original Host header, passing streamed responses through without buffering. The configurations below were tested with caddy:2 (2.11), nginx:stable-alpine (1.30) and traefik:v3 (3.7) in front of ReputeCast in October 2026.

  • Terminate TLS for the public URL’s host, and for custom domains if workspaces use them.
  • Forward every path to ReputeCast: the app and API, /hooks/… (sending providers’ webhooks), /p/… (public pages), /t/… (open and click tracking), /mcp and /healthz, which the public URL check fetches.
  • Keep the Host header. ReputeCast recognizes custom domains by it.
  • Send X-Forwarded-For. ReputeCast reads it only from trusted proxies. It doesn’t need X-Forwarded-Proto: it knows it is on HTTPS from http.public_url.
  • Stream without buffering two kinds of response:
    • the live monitor, Server-Sent Events at GET /api/v1/workspaces/{workspace}/campaigns/{campaignId}/live/stream (a heartbeat every 15 seconds, retry: 3000);
    • the MCP endpoint /mcp (Streamable HTTP), whose replies are text/event-stream.
  • Accept large request bodies. ReputeCast sets a limit per operation; the largest is 105 MiB (importing transactional templates), and contact imports accept 100 MiB.
  • ReputeCast runs with Docker Compose or as a binary.
  • The public host name (here news.example.com) points at the server, and ports 80 and 443 are open for the certificate authority and your visitors.

The Caddy and Traefik examples add the proxy to the Compose project in docker-compose.override.yml, so it reaches ReputeCast as app:8080. When the proxy runs on the server itself (a binary install, or Compose with REPUTECAST_PORT=127.0.0.1:8080), use 127.0.0.1:8080 instead.

Caddy gets and renews the certificate itself, keeps the Host header, sets X-Forwarded-For, and flushes text/event-stream responses at once, so the live monitor and MCP need no extra lines (reverse_proxy documentation, checked October 2026).

Caddyfile
{
email you@company.com
on_demand_tls {
ask http://app:8080/p/_/tls-check
}
}
news.example.com {
reverse_proxy app:8080
}
# Workspaces' custom domains for public pages: a certificate only for verified domains.
https:// {
tls {
on_demand
}
reverse_proxy app:8080
}
docker-compose.override.yml
services:
caddy:
image: caddy:2
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
app:
environment:
# The subnet of the reputecast_default network (see below).
REPUTECAST_HTTP__TRUSTED_PROXIES: 172.18.0.0/16
volumes:
caddy_data:
caddy_config:

Leave out the https:// block if no workspace uses a custom domain.

  1. Set http.public_url to the HTTPS address, without a trailing slash: REPUTECAST_PUBLIC_URL=https://news.example.com in Compose’s .env, or REPUTECAST_HTTP__PUBLIC_URL for the binary. With an https:// public URL, ReputeCast sends HSTS and marks its session cookie Secure.

  2. Set http.trusted_proxies to the addresses the proxy connects from: 127.0.0.1 for a proxy on the same server as the binary, or the Compose network’s subnet:

    Terminal window
    docker network inspect reputecast_default --format '{{(index .IPAM.Config 0).Subnet}}'
  3. Restart ReputeCast (docker compose up -d, or restart the service).

ReputeCast reads X-Forwarded-For from right to left and takes the first address that is not a trusted proxy, then falls back to X-Real-IP. Behind a chain of proxies (a CDN, then nginx), trust every hop. Without this setting, sessions, rate limits and the audit log show the proxy’s address for everyone: in testing, a session opened through nginx showed the proxy container’s 172.19.0.6 until the subnet was trusted.

A workspace can serve its subscribe page, preference center, archive and other public pages at the root of its own domain, set up under Forms & pages, then Branding & domain (brand your pages and use your own domain). The domain points at your server with a CNAME to the public URL’s host. For the proxy, that means:

  • Forward the domain to ReputeCast with its Host header. ReputeCast looks the host up among verified domains and serves that workspace’s pages; an unknown host gets the app.
  • Serve a certificate for it. With Caddy, the https:// block issues certificates on demand. Before each one, Caddy calls http://app:8080/p/_/tls-check?domain=<name>, which answers 200 for a verified custom domain and 404 for anything else, so certificates are issued only for verified domains. Check a domain yourself with curl -i "http://127.0.0.1:8080/p/_/tls-check?domain=news.acme.io".
  • nginx and Traefik have no such check: add each verified domain to server_name and its certificate (nginx), or to the router rule, as in Host(`news.example.com`) || Host(`news.acme.io`) (Traefik).

Emails keep linking to the public URL; the custom domain is for the public pages.

The SMTP relay speaks SMTP itself on 587 (STARTTLS) and 465 (TLS), with its own certificate, so HTTP proxies don’t carry it:

  • Publish the ports straight from the app container, for example in docker-compose.override.yml:

    docker-compose.override.yml
    services:
    app:
    ports:
    - "587:587"
    - "465:465"

    Or forward them at layer 4 (TCP) without terminating TLS. The relay takes the client address from the TCP connection and doesn’t read the PROXY protocol, so behind a TCP forwarder the key’s last-used address is the forwarder’s.

  • For a Let’s Encrypt certificate, ReputeCast answers the HTTP-01 challenge for the relay’s host name at /.well-known/acme-challenge/… on its web port. Let’s Encrypt fetches it over port 80 and follows redirects to HTTPS (challenge types, checked October 2026), so the proxy must forward that path for the relay’s host name. If your proxy answers that path itself, give the relay certificate files instead.

  • curl -sI https://news.example.com/healthz answers 200 with a Strict-Transport-Security header.

  • The public URL check in Instance admin, then Instance & updates shows DNS, TLS and Reachable passing (first sign-in).

  • The live stream sends data at once. With an API key that has campaigns:view:

    Terminal window
    curl -N -H "Authorization: Bearer rc_live_…" \
    https://news.example.com/api/v1/workspaces/acme-newsletter/campaigns/<campaign id>/live/stream

    You see retry: 3000 and : connected immediately, then : heartbeat … every 15 seconds.

  • Your account’s active sessions show your own IP address, not the proxy’s.

  • The live monitor doesn’t move, or jumps every few seconds: the proxy buffers the stream. In nginx, add the live-stream location with proxy_buffering off.
  • 413 Request Entity Too Large on imports or uploads: raise nginx’s client_max_body_size.
  • Everyone appears with the proxy’s address: http.trusted_proxies doesn’t include the address the proxy connects from.
  • A custom domain opens the ReputeCast app instead of the public pages: the proxy replaces the Host header, or the domain isn’t verified yet.
  • Caddy refuses TLS for a custom domain (tlsv1 alert internal error): /p/_/tls-check answered 404 because the domain isn’t verified in ReputeCast yet.
  • 502 Bad Gateway from nginx in Docker after the app was recreated: nginx kept the old container address. Restart nginx (docker compose restart nginx).
  • The public URL check warns Answered with HTTP …: the proxy answers but doesn’t forward /healthz to ReputeCast.