Self-hosting
Reverse proxy and TLS
Serve the three public origins over HTTPS, with the Caddy add-on or your own proxy, and add TLS for custom tracking hosts.
What the proxy must do
Opensend needs three public HTTPS origins. Set up TLS before you create real accounts.
| Origin | Forward to | Notes |
|---|---|---|
SITE_URL | app:3000 | The dashboard. |
CONVEX_PUBLIC_URL | convex:3210 | realtime.<domain>: the dashboard’s live updates. Forward WebSockets. |
CONVEX_PUBLIC_SITE_URL | convex:3211 | api.<domain>: REST API, SNS callbacks, tracking, one-click unsubscribe. Set X-Real-IP to the client address. |
Opensend reads the client IP for click events from X-Real-IP, so the proxy must overwrite that header rather than pass through whatever a client sent.
Keep the Convex management dashboard (port 6791) and the backend ports off the public internet. Do not publish the deployment admin key.
Use the Caddy add-on
If your platform already terminates TLS, skip this section. Otherwise the repository ships compose.caddy.yaml and docker/caddy/Caddyfile, which add Caddy with automatic certificates.
- Point DNS for the three hostnames at the server and open ports 80 and 443.
- Set the three URLs in
.env.dockertohttps://addresses. Caddy reads the same variables. - Start the stack with both Compose files:
docker compose -f compose.yaml -f compose.caddy.yaml --env-file .env.docker up -dThe Caddyfile routes SITE_URL to the dashboard, CONVEX_PUBLIC_URL to Convex (WebSockets included) and CONVEX_PUBLIC_SITE_URL to Convex HTTP actions with X-Real-IP set. It also serves custom tracking hosts with on-demand certificates, as described below. With the add-on, the app's port binds to loopback so Caddy is the only public entry point. The SMTP gateway terminates its own TLS and keeps reading its certificate from SMTP_CERT_DIR.
Dokploy, Coolify and Traefik
Platforms such as Dokploy and Coolify put Traefik in front of your containers. Route the three origins there and do not add the Caddy add-on. Forward WebSockets for CONVEX_PUBLIC_URL, and configure the router for CONVEX_PUBLIC_SITE_URL to set X-Real-IP.
Traefik's ACME resolver derives certificate names from router host rules or tls.domains. Add each custom tracking hostname to a router forwarding to convex:3211, with TLS and the same X-Real-IP handling, before verifying the CNAME. The shipped Caddy add-on supplies automatic discovery through /t/ask; Opensend does not generate Traefik routers for new tracking domains.
Custom tracking hosts
A domain can track opens and clicks through a tracking subdomain, published as a CNAME to the hostname of CONVEX_PUBLIC_SITE_URL. A CNAME alone configures neither TLS nor routing. The tracking host must terminate HTTPS on your infrastructure and forward /t/* to the Convex HTTP site on port 3211, not to the dashboard or to port 3210. Also serve /t/* on CONVEX_PUBLIC_SITE_URL itself, which links use until the CNAME verifies. Preserve the path and query, disable caching, and overwrite X-Real-IP.
The Caddy add-on already does this. For your own Caddy, restrict on-demand TLS with Opensend's ask endpoint. Merge these rules into your Caddyfile, replacing the host and upstream with your values:
{
on_demand_tls {
ask http://convex:3211/t/ask
}
}
api.example.com {
reverse_proxy convex:3211 {
header_up X-Real-IP {remote_host}
}
}
https:// {
tls {
on_demand
}
handle /t/* {
reverse_proxy convex:3211 {
header_up X-Real-IP {remote_host}
}
}
handle {
respond 404
}
}Caddy calls GET /t/ask?domain=<hostname> before requesting a certificate. Opensend answers 200 only for a verified tracking CNAME that belongs to a live domain with tracking enabled and points at this installation; every other name gets 403. The certificate issuer still performs its own domain validation. Ports 80 and 443 must reach Caddy, and the hostname's CAA policy must allow Caddy's certificate issuer.
Configure routing and TLS before you verify the CNAME: verification immediately switches new sends to that hostname. If you later change a domain's tracking subdomain, keep the old hostname's configuration explicitly; the ask endpoint allows current names only.
Cloudflare Tunnel
Add the tracking hostname as another published hostname (an ingress hostname entry) forwarding to http://convex:3211, and let Cloudflare provide edge TLS for it. Pointing DNS at the installation hostname does not add an ingress rule. Verify the tracking CNAME while the record is DNS-only: proxied records hide the CNAME answer, and a later DNS check would mark it pending and move new sends back to the installation origin. Have a trusted proxy set X-Real-IP from Cloudflare's authenticated client-IP header rather than accepting the header from clients.