07 Domains and HTTPS

Chapter 07

Domains and HTTPS

Certificates are automatic and you never handle one. The single thing that can go wrong is DNS, and it goes wrong in a specific, recognisable way.

Publish in the right order

Certificates are issued by proving control of the domain over HTTP, which means the domain must already resolve to this box before you publish. The order that works:

  1. Point the DNS record at the box and wait for it to propagate.
  2. Confirm it resolves — dig +short app.example.com should return the box address.
  3. Publish the site in the panel.

Publishing before DNS is correct is not destructive. The route is registered and the certificate simply stays pending, retrying in the background. The panel shows a DNS-not-ready warning on the site while that is the case. Once DNS resolves, the certificate is issued without further action from you.

Certificate pending looks like a broken site

While a certificate is pending, HTTPS requests fail — a browser warning or a connection error, depending on the client. The site is not broken and the container is fine. Check DNS first, and give it time before assuming something needs fixing.

What you get

Certificates
Issued and renewed automatically from Let's Encrypt. Nothing to install, configure or remember to renew.
HTTP
Handled at the edge. Your container only ever sees plain HTTP on port 80 from the proxy, which is why your application has to be told to trust the forwarded headers — the next section is how.
Wildcards
Not supported. Each domain gets its own certificate, so each one needs its own DNS record.
On a development box, expect a browser warning

Development installations typically use Let's Encrypt's staging environment, which issues certificates from an untrusted authority. The "your connection is not private" warning is expected there and does not indicate a misconfiguration. On production it should never appear.

For a stack, adding the domain is publishing it

A custom stack has no publish toggle. You add a row pointing a domain at one of the stack's allocated ports, and that row existing is what makes it served; removing the row unpublishes it. The DNS-first order above is unchanged, and so is everything on this page about certificates. Rows can only be added after the stack's first successful deploy.

Your app sees the proxy, not the visitor

This concerns anything that runs your own code — the Laravel App image from chapter 03, and a custom stack. Static Website and Single-Page App serve files and never look at who asked, so there is nothing to do there.

Traffic reaches your container only through the edge proxy, which terminates TLS and then dials the container over plain HTTP on the internal network — there are no published host ports (chapter 02.1). So until you tell your application otherwise, every request looks the same to it:

  • $request->ip(), and the equivalent in every other framework, returns the proxy's own container address — the same address for every visitor on earth.
  • The scheme reads as http, even though the visitor is on https.

Four things break from that, and every one of them breaks quietly:

  • Rate limiting collapses into a single bucket. Every visitor shares one counter, because every visitor has the same apparent address. One busy user locks out everybody, and an attacker is throttled no harder than anyone else. Nothing looks wrong until the day it matters.
  • Logs and audit trails record the proxy. Sign-in history, abuse reports, "last seen from" — one useless address in every row, and there is no fixing it afterwards, because the real address was never written down.
  • Any IP check in your application is meaningless. Admin allowlists, geo rules, "signed in from a new location" emails: they all compare against the proxy.
  • Absolute URLs your app generates come out http://. Password-reset links, OAuth redirect URIs, email links, canonical tags, asset URLs — which you meet as mixed-content blocks, redirect loops, and callback-mismatch errors from identity providers.

The fix, and why it is a wildcard here

It is one setting: tell your framework to trust the forwarded headers. Caddy sends four — X-Forwarded-For, X-Forwarded-Host, X-Forwarded-Port and X-Forwarded-Proto.

The usual advice is never to trust every proxy, and to name your proxy's address instead. That advice exists because a visitor could otherwise send a forged X-Forwarded-For and be believed. On this box they cannot:

  • Caddy overwrites any X-Forwarded-For that arrives from outside, unless the sender is a proxy it has been told to trust — and that list is empty. A header a visitor sends never survives the edge.
  • Nothing else can reach your container. No host ports open to the internet, internal network only.

So the header your app reads was written by Caddy, about the connection Caddy actually accepted, and trusting it is correct. Naming the proxy's address instead is not an option worth chasing: it is a container address, it is not stable, and it is not knowable from inside your app. Laravel 11 or 12, in bootstrap/app.php:

$middleware->trustProxies(
    at: '*',
    headers: Request::HEADER_X_FORWARDED_FOR
        | Request::HEADER_X_FORWARDED_HOST
        | Request::HEADER_X_FORWARDED_PORT
        | Request::HEADER_X_FORWARDED_PROTO,
);

The instruction is the same whatever you run — Express takes app.set('trust proxy', true), Django takes USE_X_FORWARDED_HOST = True with SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https'). A stack needs it in whichever framework its containers run.

The wildcard is true here and nowhere else

Trusting every proxy is safe because of what sits in front of your container on this box. Somewhere with a load balancer you do not control, or a container reachable directly, the same line lets any visitor claim any address they like.

If the repository also deploys elsewhere, make the trusted proxies configurable rather than copying the '*' across.

The same caution applies to one thing on this box: a service in a custom stack can also be reached through a port an administrator opened on the server, which skips the proxy entirely. A request arriving that way carries whatever headers the sender chose. Open such a port for a protocol that is not HTTP — that is what it is for — and keep it away from anything that reads X-Forwarded-For. Chapter 02.2.

Extra domains

A catalog site can answer on more than one domain — www.example.com alongside example.com, a legacy domain, a vanity domain. These are added as aliases on the existing site rather than as separate sites. (A stack does not use aliases: every one of its domains is an equal row carrying its own port.)

The form that adds one offers a choice, made once and only at that moment: the alias either serves the site, which is what an alias has always done, or it redirects to the site's primary domain. The rest of this section is about a serving alias; the next one is about the other kind.

Each alias behaves as its own route with its own certificate, so each one needs its own DNS record pointing at the box. An alias whose DNS is not ready stays pending exactly as a primary domain would. That holds for both kinds.

Two properties worth knowing:

  • A serving alias inherits the site's lock state. Locking a site locks every way into it; you cannot get round a lock by using an alias.
  • Aliases never affect your folder. The primary domain names the folder, and adding, removing or renaming aliases leaves the folder exactly where it is.

An alias can be promoted to become the primary domain. That is a rename, not a rebuild — no downtime, no re-provisioning, and the folder does not move. Which means a site's folder name and its primary domain can legitimately differ after a promotion, and that is fine.

Domains are unique across everything

A domain can be claimed once — by one site, one alias, one internal service route, or one stack's routing row. Attempting to reuse one is refused at the point you enter it, with a message naming what already holds it, rather than failing later.

A domain that only redirects

Tick the redirect box when you add the alias and the domain stops being a way into the site and becomes a signpost to it. That is what you want for a domain whose only job is to send people somewhere else: an old brand name, one bought defensively, or a www. variant you would rather not have indexed as a second address for the same pages. Leave the box alone and you get the serving alias above.

The visitor's address bar changes to your primary domain, and the rest of the address is kept — old.example.com/prices?x=1 arrives at app.example.com/prices?x=1.

It is a permanent redirect, and it is remembered

Browsers and search engines hold on to a permanent redirect for a long time, which makes this the one part that is awkward to take back. Undoing it means removing the alias and adding it again with the box unticked — nothing is lost, since an alias holds nothing but the domain — but a browser that has already learned the redirect will keep following it until it forgets on its own.

DNS is no different here. The domain still has to point at the box before it does anything, and it still gets its own certificate. "It only redirects, so there is nothing to set up" is the expectation to drop.

Two things behave differently from a serving alias, and both are worth knowing before you rely on them:

  • It keeps working while the site is down or paused. Nothing of the site is involved in answering, so the redirect is unaffected by whatever is happening behind it.
  • The site's lock does not cover it. Locking a site does not stop that domain redirecting. It is deliberate — all the redirect gives away is your primary domain, which is public anyway — but it is worth knowing if you locked the site expecting every address to fall silent. Chapter 08.

One button is missing on a redirecting alias: Make primary is offered only for aliases that serve. A redirecting domain points at the primary domain, so promoting it would leave it pointing at itself.