Your first app / Chapter 8

By the end of this chapter you should be able to

  • Explain how one port can serve several applications
  • Run a reverse proxy from reviewed routing files without giving it Docker access
  • Give an application a hostname, and reach it without publishing its port
  • Say what a certificate asserts, and why your browser believes some and not others

You have one application and it is awkward to reach. You are about to have several, and they cannot all have port 443.

The fix is a reverse proxy: one program that accepts every incoming request and decides which application should handle it. Forward proxies sit in front of clients and speak on their behalf. Reverse proxies sit in front of servers and answer on theirs.

How one port serves many applications #

When your browser opens https://photos.example.com, it resolves the name to an address, connects, and then sends the name again, in the request:

GET / HTTP/1.1
Host: photos.example.com

That Host header is the whole trick. The proxy reads it and picks a backend. Ten hostnames can point at the same address and get ten different applications, because the name survives the journey.

One port, routed by hostname Three browsers ask for three different hostnames. All three resolve to the same address and the same port. The reverse proxy reads the requested name and forwards each request to a different application, each on its own network. Three requests, one address Host: photos.example.com Host: files.example.com Host: tv.example.com Reverse proxy one address port 443 reads the name, picks a backend Photo library its own network, no published port File storage its own network, no published port Video its own network, no published port The name travels twice: once in the TLS handshake as SNI, so the proxy can choose a certificate before it can decrypt anything, and again inside the request as the Host header, so it can choose a backend afterwards. SNI is not encrypted, so the hostname you visit is visible on the network even when everything else is not.
One address, one port, and a header that says which application the request was for.

HTTPS adds a wrinkle. The proxy has to choose a certificate before it can decrypt anything, and the Host header is inside the encrypted part. So the client also announces the name during the handshake, in the clear, in a TLS extension called SNI. Server Name Indication is how the proxy knows which certificate to present.

Two consequences apply to this setup. A network observer can usually see the SNI hostname; TLS Encrypted Client Hello can hide it when both client and service deploy it, but the private setup below does not. A client too old to send SNI gets the proxy's default certificate.

Keep the proxy away from Docker #

This book uses Traefik. Traefik can discover routes from Docker labels, but discovery requires Docker API access. That API exposes container configuration and, through read-only GET endpoints, can expose logs, process arguments and files mounted inside containers.

Putting docker-socket-proxy in front does not make this a metadata-only channel. POST=0 blocks mutating requests, but a current upstream issue demonstrates container archives and arbitrary files being read with the container endpoint enabled. That includes the file-backed secrets introduced in chapter 9.

For a single home server, automatic discovery is not worth that confidentiality boundary. Use Traefik's file provider instead. Every router and backend is a small YAML file in the repository, reviewed before deployment. Adding an application means adding a route explicitly.

What did file configuration cost?Show me why

You lose automatic route discovery. A new container does nothing until you add and deploy a router, which is the safer failure direction. The route file also records the hostname, entrypoint and backend in Git without giving the proxy a machine-wide inspection channel.

Connect only containers that need each other #

Chapter 6 mentioned that containers on different networks cannot see each other. This is where that matters.

Give the proxy a network per application, and each application only the network it needs:

networks:
  edge: {}              # ordinary network: Traefik publishes the LAN port
  edge-photos:
    internal: true      # only the proxy and the photo library
  immich-backend:
    internal: true      # the photo library, its database and its cache
  app-egress: {}        # outbound access for the Immich server
  ml-egress: {}         # outbound only, for model downloads

Traefik joins edge and edge-photos. Immich joins edge-photos, immich-backend and app-egress. The ordinary egress network lets it reach supported external APIs and, in chapter 13, the private Authentik issuer. No port is published merely by joining that network.

Traefik needs one network that is not internal

This catches people and the failure is silent. A container attached only to internal: true networks gets no port mapping at all: Docker accepts the ports: line, starts the container, and creates nothing. docker ps shows 443/tcp rather than 0.0.0.0:443->443/tcp, and every connection is refused.

An internal network has no route in or out, and a published port depends on exactly that path. So the proxy needs edge, which is an ordinary network with nothing else on it.

Later monitoring services that need internet egress for email also use an ordinary network. Treat internal: true as an isolation control that must match the service's real communication needs.

One compose project or two

Simplest is to keep everything in one project, and that is what this chapter assumes: add the proxy services to the compose file you wrote in chapter 7, so name: immich covers both and the networks are all in one place.

If you split them into separate projects later, declare the shared application network external: true in the second project. Docker prefixes project network names, so check the actual name with docker network ls and make the file provider's backend name resolvable on that network.

The result is that if the photo library is compromised, it can talk directly to its own database, cache and proxy. It cannot directly reach another application's private bridge or database. Published host services and explicitly routed egress remain separate paths, so test the actual network rather than treating a bridge name as a complete security boundary.

Internal networks and the internet

internal: true means no route out to the internet either, which is what you want for a database and not what you want for an application that has to fetch things.

The photo library's machine learning service downloads models on first use, so it needs a way out. Give it a second, non-internal network of its own:

  ml-egress: {}          # outbound only, nothing else joins it

and put immich-machine-learning on immich-backend and ml-egress. It can then reach the internet and still cannot be reached from it, because nothing publishes a port to it.

The proxy itself #

Traefik splits configuration in two.

Static configuration is what Traefik reads once at startup: which ports to listen on, which providers to use. Changing it means restarting. It goes in the command line or in a traefik.yml.

Dynamic configuration is routing: hosts, services and certificates. Traefik watches the files you point it at and applies changes without restarting.

  traefik:
    image: traefik:v3.7@sha256:<pin it>
    restart: unless-stopped
    command:
      - --providers.file.directory=/etc/traefik/dynamic
      - --providers.file.watch=true
      - --entrypoints.websecure.address=:443
      - --api.dashboard=false
      - --log.level=INFO
    ports:
      - "192.168.1.20:443:443"
    volumes:
      - ./dynamic:/etc/traefik/dynamic:ro
      - ./certs:/etc/traefik/certs:ro
    networks: [edge, edge-photos]
    mem_limit: 256m
    security_opt: [no-new-privileges:true]

The file provider watches the read-only dynamic/ directory. Traefik has no Docker socket, proxy endpoint or container-discovery provider. --api.dashboard=false documents that the dashboard is disabled; if you enable it later, keep it on the private administration path.

There is one TLS entrypoint and one published port. Binding it to 192.168.1.20 prevents this Compose file from silently widening the listener to every host interface. Chapter 12 adds a separate loopback endpoint for private Tailscale Serve rather than a plaintext proxy route.

The bind address is the control

"192.168.1.20:443:443" publishes only on the server's chosen LAN IPv4 address. It does not also bind loopback, the tailnet address or IPv6. Confirm with ss -tlnp and test from a second LAN device.

The traffic still follows Docker's forwarding path, so a UFW INPUT rule would not enforce this boundary. The bind also cannot protect you from a router rule that forwards internet traffic to this LAN address. Verify that the router has no forwarding or UPnP mapping for 443, and inspect globally routed IPv6 separately.

Route the photo library from a file #

Remove the loopback ports: block from immich-server and attach it to the proxy network:

  immich-server:
    # ...everything from chapter 7, except the `ports:` block
    networks: [edge-photos, immich-backend, app-egress]

Then add the reviewed dynamic route:

dynamic/photos.yaml
http:
  routers:
    photos-private:
      rule: Host(`photos.example.com`)
      entryPoints: [websecure]
      service: photos
      tls: {}
  services:
    photos:
      loadBalancer:
        servers:
          - url: http://immich-server:2283

Read it as a sentence. The photos-private router matches the hostname on the TLS entrypoint and sends it to the photos backend. Traefik resolves immich-server on edge-photos; no Docker metadata is involved. The tls: {} router handles the LAN's encrypted route only.

Delete the published port

The ports: block from chapter 7 comes out entirely. Not changed, removed.

The application no longer needs a port on the host: Traefik reaches it over the Docker network by container name. Nothing is published, so nothing bypasses your firewall, so chapter 6's trap cannot recur here. Notice that the safe arrangement is also the simpler one.

Names that resolve #

photos.example.com has to mean something to your browser. You do not own a domain yet and you do not need one; three options, in increasing order of effort.

Your laptop's hosts file is the two-minute version. On macOS and Linux, /etc/hosts; on Windows, C:\Windows\System32\drivers\etc\hosts:

192.168.1.20  photos.example.com

Works immediately, on that one machine only, and you will forget you did it. Fine for testing, painful for a household.

Your router can usually do this for everyone at once, under DNS or DHCP settings, sometimes called static DNS or local DNS entries. Better, when the router allows it. Many do not.

A resolver on the server is the thorough answer, and it is more than this chapter needs. Chapter 10 gets you most of the way there for free, because the private network brings its own naming.

Use a domain you already control

Do not invent photos.home or photos.local. .local is reserved for multicast DNS, and an invented public suffix may already belong to somebody else.

Use a subdomain of a registered name you control. If this service will remain purely private and you do not own a domain, use a name below the reserved home.arpa suffix and substitute it consistently; it cannot later become the public name from chapter 12.

What a certificate actually asserts #

Time to say what the route's tls: {} setting relies on.

A certificate is a public key, plus a list of names, plus a signature from somebody else saying they checked. That is all. It asserts: whoever holds the matching private key is entitled to serve these names, and I, the issuer, vouch for that.

Your browser believes it because the issuer's own certificate is in a list your operating system ships, curated by your OS or browser vendor. Public authorities get into that list by passing audits and proving they only issue after verifying control of a domain. That verification is the entire product.

A certificate says nothing about whether a site is honest. It says the connection is encrypted and the name matches. Phishing sites have perfectly good certificates.

For this first local route, you create a private authority. A public authority could validate an owned domain through DNS without reaching the service, but a private authority works without publishing certificate issuance and teaches the trust path directly.

Side readingWhat TLS actually assertsCertificates, chains of trust, and why a private authority is fine at home and worthless in public.

Making one #

Run these on your laptop, not on the server. The CA private key they produce should never be on the machine it issues certificates for. The leaf certificate and key go to Traefik; the public CA certificate can go to clients and monitoring.

Create an encrypted CA key and a self-signed certificate. OpenSSL prompts for a passphrase; keep it in the offline recovery record, not in shell history:

$ umask 077
$ openssl req -x509 -newkey rsa:3072 -sha256 -days 3650 \
    -keyout ca.key -out ca.crt -subj "/CN=Home Server CA" \
    -addext "basicConstraints=critical,CA:TRUE,pathlen:0" \
    -addext "keyUsage=critical,keyCertSign,cRLSign"

Then a certificate for your names, signed by it:

$ openssl req -new -newkey rsa:3072 -noenc -keyout wildcard.key -out wildcard.csr \
    -subj "/CN=*.example.com"
$ printf '%s\n' \
    'basicConstraints=critical,CA:FALSE' \
    'keyUsage=critical,digitalSignature,keyEncipherment' \
    'extendedKeyUsage=serverAuth' \
    'subjectAltName=DNS:*.example.com,DNS:example.com' > ext.cnf
$ openssl x509 -req -in wildcard.csr -CA ca.crt -CAkey ca.key -CAcreateserial \
    -days 825 -sha256 -extfile ext.cnf -out wildcard.crt
$ chmod 0600 ca.key wildcard.key

Read back what you made:

$ openssl x509 -in wildcard.crt -noout -subject -issuer -dates -ext subjectAltName

Confirm the Subject names your wildcard, the Issuer is Home Server CA, the validity dates are the ones you intended, and Subject Alternative Name contains both entries. Record your own output; certificate dates must not be copied from the example book.

Transfer only the leaf pair and public CA certificate, then install them without committing private key material:

$ scp wildcard.crt wildcard.key ca.crt \
    admin@192.168.1.20:/var/tmp/
$ ssh -t admin@192.168.1.20 \
    'install -d -m 0700 ~/homeserver/immich/certs && \
     install -m 0644 /var/tmp/wildcard.crt ~/homeserver/immich/certs/wildcard.crt && \
     install -m 0600 /var/tmp/wildcard.key ~/homeserver/immich/certs/wildcard.key && \
     sudo install -d -o root -g root -m 0755 /etc/homeserver/trust && \
     sudo install -o root -g root -m 0644 /var/tmp/ca.crt /etc/homeserver/trust/home-server-ca.crt && \
     rm -f /var/tmp/wildcard.crt /var/tmp/wildcard.key /var/tmp/ca.crt'

The CA certificate is public. Keeping a copy on the server does not grant signing authority; ca.key remains encrypted on the laptop and in its recovery copy.

Why the extension file, and why 825 days?Show me why

The subjectAltName extension is not optional any more. Browsers stopped honouring the common name for hostname matching, so a certificate without a SAN list matches nothing at all, however correct it looks.

825 days is the old public maximum, and public certificates are far shorter now. Yours is private, so you set the rule; the reason not to make it ten years is that a long-lived certificate is a long-lived problem if its key leaks. ca.key is the file that really matters: anyone holding it can mint a certificate for any name, and every device that trusts your CA will believe it.

Point Traefik at the certificate with a dynamic configuration file:

dynamic/tls.yaml
tls:
  stores:
    default:
      defaultCertificate:
        certFile: /etc/traefik/certs/wildcard.crt
        keyFile: /etc/traefik/certs/wildcard.key

Trusting it #

Right now nothing believes your authority. Copy ca.crt to each device and add it to the trust store: Keychain Access on macOS, /usr/local/share/ca-certificates/ plus sudo update-ca-certificates on Debian and Ubuntu, Settings then Security on Android, a profile on iOS. Firefox keeps its own store and needs telling separately.

Do not copy ca.key anywhere

Only ca.crt, the public half, goes on your devices. ca.key stays on your laptop, never on the server, and wants an offline backup. A leaked CA key means anybody can impersonate any site to every device that trusts you, and revoking a private authority means visiting every device by hand.

Prove it #

Bring it up and test the three cases that matter.

For the tests themselves, bring back the echo service from chapter 6. It reports what it received, which is exactly what you want when checking routing, and the real application would just return HTML:

  whoami:
    image: traefik/whoami
    networks: [edge-photos]

Add its temporary file-provider route:

dynamic/whoami.yaml
http:
  routers:
    whoami:
      rule: Host(`whoami.example.com`)
      entryPoints: [websecure]
      service: whoami
      tls: {}
  services:
    whoami:
      loadBalancer:
        servers:
          - url: http://whoami:80
$ sudo docker compose up -d

Remove the service and dynamic/whoami.yaml once the three tests below pass, then run sudo docker compose up -d --remove-orphans.

A hostname with a router, verified against your own authority:

$ curl --resolve whoami.example.com:443:192.168.1.20 \
    --cacert ca.crt https://whoami.example.com/

Confirm the response says Host: whoami.example.com. RemoteAddr will be a Docker-network address belonging to the proxy, not the laptop. Record the address your own network assigned rather than copying an example.

A hostname with no router:

$ curl --resolve nope.example.com:443:192.168.1.20 \
    --cacert ca.crt -o /dev/null -w "%{http_code}\n" https://nope.example.com/

TLS succeeded and routing failed, which is exactly right: the certificate covers the whole wildcard, and no router matched the name.

Without trusting your authority:

$ curl --resolve photos.example.com:443:192.168.1.20 \
    -o /dev/null https://photos.example.com/

This should fail certificate verification on a client that does not trust your CA. Do not add -k: that would disable the control you are testing. Repeat with --cacert ca.crt and require success.

Now open it in a browser on a device where you installed the CA.

My browser will not trust the certificate

Work out which link in the chain my device is rejecting

Paste this into a new agent session. It carries everything the agent needs to know about where you are, and asks it to walk you through the problem rather than fix it for you.

I made a private certificate authority and a certificate for my server, installed the CA on my machine, and my browser still complains. I would like to work out whether the problem is the certificate, the trust store, the hostname, or the proxy configuration.

Traefik returns 404 for a hostname I configured

Find out why the router is not matching

Paste this into a new agent session. It carries everything the agent needs to know about where you are, and asks it to walk you through the problem rather than fix it for you.

I added a Traefik dynamic file and requests to that hostname return 404 rather than reaching the application. TLS itself works. I want to check whether Traefik loaded the file and whether the router rule or backend address is wrong.
You never added a firewall rule for 443. Why was the proxy reachable anyway?Show answer

Because publishing a port routes traffic to the container rather than delivering it to the host, so it traverses FORWARD and your INPUT rules never see it. That is chapter 6's lesson, unchanged.

The difference is that here it is intentional. The LAN-address bind controls which host address listens; UFW INPUT is not what makes it work and would not stop the forwarded packets.

What you have, and what is still missing #

Every application from here on gets a hostname and no published port. Adding one means attaching the proxy network and adding a reviewed dynamic route.

Three things this does not do, so you know what the next chapters are for. It is reachable on your own network only. The certificate is trusted by devices you configured by hand, which does not scale to visiting relatives. And the proxy sees every request in plaintext after decryption, which is inherent to what a reverse proxy is, and worth knowing rather than discovering.

Done when

  • Traefik uses only the file provider and has no Docker socket or API endpoint
  • dynamic/photos.yaml names the TLS router and exact backend URL
  • The dashboard is off
  • Your photo library has no ports: block at all
  • A hostname resolves to your server and reaches the application over HTTPS
  • openssl x509 -noout -subject -issuer -ext subjectAltName shows what you expect
  • A hostname with no router returns 404 rather than reaching something
  • A normal curl fails before CA trust is supplied, and --cacert verifies
  • The echo service used for the tests has been removed again
  • ca.key is not on the server, and you know where it is
  • A browser on a device you configured shows a padlock

What you picked up

  • One port serves many applications because the requested name travels with the request, in the Host header and, for TLS, in SNI.
  • File-provider routes avoid giving the proxy Docker API access; each new route is an explicit reviewed change.
  • A network per application limits direct bridge reachability; published ports and other routes still need separate controls.
  • Behind a proxy, applications need no published ports. The safe arrangement is also the simpler one.
  • A certificate asserts name control and nothing about honesty. Your browser believes issuers on a list it shipped with.
  • You can be your own authority for private names. Protect the encrypted CA private key offline.
  • Test all three cases: routed, unrouted, and untrusted. Each failure looks different, and knowing which is which is most of the debugging.

Settings

Your values

The book is written with placeholder names so it makes sense to everybody. Put your own in and every chapter, every command and every copy-paste prompt updates to match.

Nothing here is sent anywhere. It is saved in this browser, so it comes back next time. A different browser or a private window gets the placeholders again.

Live preview

$ ssh admin@192.168.1.20
$ sudo ufw allow from 192.168.1.0/24 to any port 22 proto tcp
$ sudo hostnamectl set-hostname homeserver
$ sudo timedatectl set-timezone Europe/Paris

Real commands from chapters 2, 3 and 4. They change as you type.

The account you log in as. Not root, and not necessarily the same name you use on your laptop.

Introduced in Chapter 2, Meet your server

The book's placeholder is admin

What the machine calls itself. You choose it, and it shows up in your shell prompt and your logs.

Introduced in Chapter 3, A safe front door

The book's placeholder is homeserver

The IP address your server has on your home network, from ip -brief addr.

Introduced in Chapter 2, Meet your server

The book's placeholder is 192.168.1.20

The address range and prefix shown by ip route or ip -brief addr, written in CIDR form. Copy the real prefix; do not guess /24.

Introduced in Chapter 2, Meet your server

The book's placeholder is 192.168.1.0/24

The address traffic goes to on its way out of your house, from ip route.

Introduced in Chapter 2, Meet your server

The book's placeholder is 192.168.1.1

In Region/City form, or Etc/UTC if you would rather read logs in UTC.

Introduced in Chapter 3, A safe front door

The book's placeholder is Europe/Paris

A registered name you control. Chapter 8 uses it for the LAN route; chapter 12 uses a separate private Tailscale name remotely.

Introduced in Chapter 8, One door, many rooms

The book's placeholder is example.com

The email identity allowed to administer the tagged server in your Tailscale policy.

Introduced in Chapter 10, Your own private network

The book's placeholder is you@example.com

The mailbox that should receive actionable home-server alerts.

Introduced in Chapter 14, Knowing it is alive

The book's placeholder is alerts@example.com

Once you save, the prose and the commands read with your names, the copy buttons copy your values, and the copy-paste prompts describe your machine accurately. That last one matters: an assistant told your network is 192.168.1.0/24 when it is not will send you chasing the wrong thing.

Anything you leave empty keeps the book's placeholder.