Your first app Chapter 8 80 min
One door, many rooms
One port, several applications, routed by hostname. Plus what a certificate actually proves.
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.
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:
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:
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:
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.
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.
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.yamlnames 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 subjectAltNameshows what you expect - A hostname with no router returns 404 rather than reaching something
- A normal
curlfails before CA trust is supplied, and--cacertverifies - The echo service used for the tests has been removed again
-
ca.keyis 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
Hostheader 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.