Behind a proxy

A proxy is a server that sits between your visitors and your site. This chapter is for sites that sit behind one that handles https for them: a Cloudflare Tunnel, Pangolin, the Cloudflare proxy, or a proxy of your own such as nginx. At the end your site answers at your domain through the proxy, and you know which line in .env to change to move it between setups.

You need a site that already runs on a server, or at least the files from Run it on a server. As there, example.com stands for your domain. If you have one server, a domain and nothing in front of it, you do not need this chapter. The setup in the previous chapter is simpler.

When you would want this

  • Your domain is on Cloudflare and you want to keep its protection and caching.
  • The server is at home, or on a network with no public address. A tunnel connects from your server out to the proxy, so you open no ports at all.
  • You already run Pangolin, or another proxy that serves several sites from one machine, and this site should join them.

Cloudflare and Pangolin rename things in their dashboards from time to time. The steps below describe what each step does, so that you can find it even when the name has changed.

How the setups differ

One line in .env, COMPOSE_PROFILES, picks which Caddy runs in front of the site. In https mode Caddy gets its own certificate. In proxy mode Caddy serves plain http inside your server and leaves https to the proxy. A tunnel connector is one more profile beside it, and it reaches Caddy over Docker's own network, so nothing is published to the internet.

SetupCOMPOSE_PROFILESWhat runsPorts to open
Caddy gets the certificate (the previous chapter)httpscaddy-https80 and 443
Cloudflare Tunnelproxy,cloudflarecaddy-proxy and cloudflarednone
Pangolinproxy,pangolincaddy-proxy and newtnone
Cloudflare proxy, no tunnelhttpscaddy-https80 and 443
Another proxyproxycaddy-proxyone port, on the address in CMS_BIND

Why CMS_SITE_URL must be the public https address

Whatever the setup, CMS_SITE_URL in .env is the address your visitors type, for example https://example.com. It is not the address of your server or of Docker. The site uses it to:

  • mark its cookies Secure, so that browsers send them only over https;
  • decide that the X-Forwarded-* headers from Caddy can be trusted, which is how the site learns that the visitor used https;
  • check that sign-in and form requests come from your own pages;
  • write the links in emails and the return addresses for sign-in providers.

The scaffold's .env starts with http://127.0.0.1:4321, which is right on your own computer and wrong behind a proxy. If you forget to change it, sign-in fails; Troubleshooting shows how to recognise that.

Cloudflare Tunnel

A Cloudflare Tunnel needs your domain's DNS to be on Cloudflare. A small program, cloudflared, runs next to the site and holds a connection open to Cloudflare, which sends your visitors down it. Compose runs that program for you.

  1. In the Cloudflare dashboard, open Zero Trust and create a tunnel. Choose the Cloudflared type and give it a name, such as my-site.
  2. Cloudflare then shows install commands for several systems. Do not run them. Copy the tunnel token instead, the long string after --token.
  3. Add a public hostname to the tunnel. Use your domain, set the service type to HTTP and the URL to caddy-proxy:80. Cloudflare creates the DNS record for you.
  4. On the server, set these in .env. CMS_AUTH_SECRET stays as it is.
.env
CMS_SITE_URL=https://example.com
COMPOSE_PROFILES=proxy,cloudflare
CLOUDFLARE_TUNNEL_TOKEN=paste-the-token-here
  1. Start the site. If it is already running in another setup, follow “Switching between setups” below instead.
docker compose up -d --build
  1. Check that the connector is running and that Cloudflare shows the tunnel as connected.
docker compose logs --tail 20 cloudflared

Then open your domain. On a new site, create your admin account as in Run it on a server. No firewall ports are needed here beyond SSH.

Pangolin

Pangolin is a self-hosted proxy. A small connector, Newt, runs next to your site and connects out to your Pangolin, which serves your domain and handles the certificate.

  1. In Pangolin, create a site of the Newt type. Pangolin shows its address (the endpoint), an ID and a secret. Copy all three.
  2. Create a public HTTP resource for your domain. Give it one target: the site you made in the last step, method http, address caddy-proxy, port 80.
  3. On the server, set these in .env:
.env
CMS_SITE_URL=https://example.com
COMPOSE_PROFILES=proxy,pangolin
PANGOLIN_ENDPOINT=https://pangolin.example.com
NEWT_ID=paste-the-id-here
NEWT_SECRET=paste-the-secret-here
  1. Start the site, or follow “Switching between setups” if it is already running.
docker compose up -d --build
  1. Check that Newt connected. Pangolin should show the site as online.
docker compose logs --tail 20 newt

The Cloudflare proxy, without a tunnel

Here your domain's DNS record points at your server and the cloud next to it is orange, so visitors reach Cloudflare first and Cloudflare reaches your server. This is the https setup from the previous chapter with three changes. Caddy still gets a real certificate, which Cloudflare checks.

  1. In Cloudflare, set the SSL/TLS mode for the domain to Full (strict).
  2. Set up the server as in Run it on a server, with COMPOSE_PROFILES=https. If Caddy cannot get its certificate while the cloud is orange, set the record to DNS only until it has one, then turn the proxy back on.
  3. Tell Caddy which header holds the visitor's address. Behind Cloudflare it is CF-Connecting-IP. Add this to .env:
.env
CMS_CLIENT_IP_HEADERS=CF-Connecting-IP
  1. Tell Caddy to believe Cloudflare. Cloudflare connects from its own public addresses, and Caddy ignores forwarded headers from addresses it does not trust. Without this the site sees Cloudflare's address for every visitor, which weakens the rate limits on sign-in. Cloudflare publishes the addresses and can change them. This command fetches them and adds the line to .env:
printf 'CMS_TRUSTED_PROXIES="private_ranges %s"\n' "$({ curl -s https://www.cloudflare.com/ips-v4; echo; curl -s https://www.cloudflare.com/ips-v6; } | tr '\n' ' ')" >> .env

private_ranges keeps Docker's own network trusted. If you run the command again later, delete the old CMS_TRUSTED_PROXIES line from .env first, and look at Cloudflare's list now and then.

  1. Apply the change.
docker compose up -d

Another proxy on the same machine or the network

If you already run nginx, Traefik, HAProxy or another Caddy, let it handle https and give it the site over plain http. Use COMPOSE_PROFILES=proxy. Caddy then publishes the site's port, 4321 unless you chose another, on the address in CMS_BIND. That address is 127.0.0.1 by default, so only the machine itself can reach it, which suits a proxy on the same machine. Point that proxy at http://127.0.0.1:4321.

.env
CMS_SITE_URL=https://example.com
COMPOSE_PROFILES=proxy

Your proxy has to pass on the original Host header and set X-Forwarded-Proto: https and X-Forwarded-For. The site depends on them to know that the visitor used https.

When the proxy runs on another machine, set CMS_BIND to the server's address on the private network between the two, such as 10.0.0.5. Do not use 0.0.0.0. That publishes the port on every network the server is on, the internet included, and ufw does not stop Docker's published ports.

.env
CMS_BIND=10.0.0.5

Caddy believes forwarded headers only from private addresses by default. If your proxy connects from a public address, add it to CMS_TRUSTED_PROXIES, keeping private_ranges in the list:

.env
CMS_TRUSTED_PROXIES="private_ranges 198.51.100.7"

Switching between setups

To move a running site to another setup, change COMPOSE_PROFILES in .env and add the settings the new setup needs. Then remove the old Caddy and connectors by name and start the new set:

docker compose rm -sf caddy-https caddy-proxy cloudflared newt
docker compose up -d

Both steps are needed. Compose looks only at the profiles that are switched on. docker compose up -d alone starts what the new value lists and leaves the old Caddy running, still holding its ports. docker compose down removes only what the current value lists. The first command removes the old ones by name, and it does not mind if one of the names is not running.

Your data is not touched. Compose does restart the site itself, because it reads .env, so expect the site to be down for about twenty seconds while it starts and Caddy waits for it.

To stop everything the site runs, in any setup, use the following. It keeps the data; add -v to delete that too.

docker compose --profile '*' down

Next

Going live covers email, payments and a checklist for before real visitors arrive.