Documentation Menu

Behind a proxy and TLS

6 min read

Put the CMS behind a reverse proxy or an ingress that terminates TLS, then tell it its public address and how many proxies stand in front of it.

Why the CMS leaves TLS to your proxy

The CMS speaks plain HTTP on port 3000. TLS belongs to whatever sits in front of it, whether that is nginx, a load balancer, a Kubernetes ingress or a CDN, so certificates live where your other services already keep theirs. The price is that the CMS never sees the outside world directly. It does not know the address people type into the browser unless you tell it, and every request looks as if it came from the proxy unless the CMS knows how far back along the chain to look. Two settings close that gap. Get either one wrong and sign-in or rate limiting breaks, in ways that are hard to trace back to the proxy.

What the CMS needs to know about the proxy

BASE_URL is the address people and sites use to reach the CMS, such as https://cms.example.com. The CMS does not work this out from the headers of each request. It builds every absolute URL from this one value:

  • the callbacks GitHub and Google send the browser back to, <BASE_URL>/api/v1/auth/github/callback and /google/callback;
  • the URLs of images and files in delivered content, unless ASSET_PUBLIC_URL names a CDN;
  • the Secure flag on cookies: when BASE_URL starts with https://, the CMS marks the session cookie Secure, and the browser then sends it only over TLS.

TRUST_PROXY is the number of proxies between the caller and the CMS. Each proxy appends the address of whoever connected to it to X-Forwarded-For, and the CMS counts back exactly this many entries from the end of that header to find the caller. The address it lands on is the one the per-address limits count against: sign-in and registration (AUTH_RATE_LIMIT) and the delivery pre-check (DELIVERY_PREAUTH_RATE_LIMIT). The setting is a hop count on purpose, never true, and each wrong count fails in its own way:

  • Too low, such as 0 behind one proxy: every request appears to come from the proxy, so all callers share one budget. One person's mistyped password, or one busy build server, gets everyone a 429.
  • Too high: the CMS reads an entry the caller wrote themselves. A caller who sends an invented X-Forwarded-For with every request gets a fresh budget each time.
  • Right: 1 behind one proxy or ingress, 2 behind a CDN and then a proxy.

A proxy also has its own ceiling on request bodies. A request it cuts never reaches the CMS, so the person sees the proxy's error instead of the CMS's. The ceiling has to allow the largest bodies the CMS accepts, which are:

  • an upload, up to ASSET_MAX_BYTES (25mb);
  • a CLI push or any other JSON request, up to JSON_BODY_LIMIT (10mb);
  • the start of a project restore, POST /api/v1/projects/<project>/import, up to IMPORT_MAX_BYTES (256mb).

A caching proxy has to respect what delivery answers say about themselves. Each one carries an ETag, a Cache-Control whose shared-cache lifetime (s-maxage) is longer than the browser's, and Vary: Authorization. That last header is the one that matters most. Two projects' keys address URLs of the same shape, so a cache that ignored the credential would serve one project's content to another project's readers. A publish clears the CMS's own cache at once, while a cache in front of it expires on its own schedule. Private files are always sent as private, so a CDN never keeps them.

Browsers on other origins can reach only /d/v1, and only for GET, HEAD and OPTIONS without credentials. That is what lets a site read content with a delivery key. The dashboard and the management API are same-origin by design, so serve them on the CMS's own hostname, not under a path of another site.

How to

Terminate TLS in front of the CMS, forward every request to port 3000, and set the two variables.

nginx

This server block serves one CMS running on the same machine, with the certificate already in place:

server {
    listen 443 ssl;
    server_name <cms.example.com>;
    ssl_certificate     <path to fullchain.pem>;
    ssl_certificate_key <path to privkey.pem>;

    # The largest upload the CMS accepts (ASSET_MAX_BYTES).
    client_max_body_size 25m;

    # A project restore sends its metadata in one request (IMPORT_MAX_BYTES).
    location ~ ^/api/v1/projects/[^/]+/import$ {
        client_max_body_size 256m;
        proxy_pass http://127.0.0.1:3000;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }

    location / {
        proxy_pass http://127.0.0.1:3000;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

Then give the CMS its public address and tell it there is one hop:

BASE_URL=https://<cms.example.com>
TRUST_PROXY=1

Kubernetes ingress

The Helm chart already assumes one ingress hop. It sets TRUST_PROXY=1 (config.trustProxy), and it derives the nginx ingress's proxy-body-size from config.assetMaxBytes. It does not derive the host from the address, or the address from the host, so set the two together:

ingress:
  enabled: true
  host: <cms.example.com>
  tls:
    issuer: <letsencrypt-prod>
config:
  baseUrl: https://<cms.example.com>

The annotation holds one number for every path, so a restore through the ingress needs a larger one. Add nginx.ingress.kubernetes.io/proxy-body-size under ingress.annotations with the size you need; it replaces the derived value. Keep config.assetMaxBytes at or above config.jsonBodyLimit, or the ingress cuts pushes before they reach the CMS.

Restart the CMS so it reads the new values, then check it from outside:

curl -s https://<cms.example.com>/readyz

It should print {"status":"ok","mongo":"up","redis":"up"}. Then sign in to the dashboard at the same address. If the sign-in form accepts your password and puts you straight back on the sign-in page, BASE_URL says https:// while the browser reached the CMS some other way, and the browser dropped the Secure cookie.

Limits

  • One public address per deployment. The CMS builds every URL from BASE_URL, so serving it on two hostnames leaves one of them with broken sign-in callbacks and broken file URLs.
  • The CMS serves the dashboard at / and its APIs at /api, /d, /healthz and /readyz, and it cannot be moved under a path prefix of another site.
  • TRUST_PROXY is a count, not a list of trusted addresses. Where some requests pass through more proxies than others, the CMS counts some callers wrongly.
  • The CMS does not read X-Forwarded-Proto to decide whether a cookie is Secure. Only BASE_URL decides that.
  • A cache in front of the delivery API does not hear about a publish. It keeps serving the old answer until its own lifetime runs out.
  • A CDN in front of the file routes never caches private files, so every request for one reaches the CMS. Plan capacity for that where private files are requested often.
  • The Helm chart's ingress annotation sets one body limit for every route, and Helm cannot compare two bytes spellings for you.

Last updated