Tag: Networking

Network fundamentals for self-hosters: Docker networks, ports, firewalls and reaching services safely.

  • Caddy in Docker: Reverse Proxy with Automatic HTTPS for Self-Hosted Services

    Caddy in Docker: Reverse Proxy with Automatic HTTPS for Self-Hosted Services

    Every self-hosted service starts life behind a raw IP and port: http://192.168.1.42:8084. It works, but it is not the web you are used to — no HTTPS, no domain name, no sensible routing. A reverse proxy sits in front of your services and fixes all three: it owns your domain (or a local one), terminates TLS, and routes each hostname to the right container. Caddy is the reverse proxy I recommend for home servers because the configuration is a single readable file and, if you point a real domain at it, HTTPS certificates are obtained and renewed automatically with zero configuration. This guide runs Caddy in Docker Compose and walks through a Caddyfile that actually serves multiple self-hosted services.

    Intermediate · 10 min · Docker

    Everything here was tested on a Debian 12 mini PC with Docker 29.7. In the lab I test plain HTTP routing (no domain, no certificate), and I flag exactly where your setup will differ once you have a domain and a public IP.

    What Caddy does for you

    A reverse proxy receives requests on ports 80 (HTTP) and 443 (HTTPS) and forwards them to the services on your network based on the host name in the request. Three things make Caddy a good fit for a home server. First, the Caddyfile is plain, human-readable configuration — you will understand every line in the example below. Second, automatic HTTPS: if you configure a real domain, Caddy requests a Let’s Encrypt certificate for it on first use, stores it, and renews it before expiry. There is no cron job to write and no certificate to babysit. Third, it is a single small static binary in the caddy image — no database, no plugin system, fast to start and easy to restart when you edit the file.

    The flip side to understand: automatic HTTPS needs two things that a LAN-only test does not have — a domain that points at the machine, and inbound 80/443 reaching it. For purely internal use (you browse services by name on your own network), you can run Caddy in plain-HTTP mode, which is what the lab test below does.

    The compose file

    services:
      caddy:
        image: caddy:latest
        container_name: caddy
        restart: unless-stopped
        ports:
          - "127.0.0.1:8083:80"
          - "127.0.0.1:8443:443"
        volumes:
          - ./Caddyfile:/etc/caddy/Caddyfile:ro
          - caddy_data:/data
          - caddy_config:/config
        environment:
          - TZ=Europe/London
    
    volumes:
      caddy_data:
      caddy_config:

    Two volumes are the only persistent state. caddy_data stores the TLS certificates it obtains (empty in the lab test); caddy_config stores Caddy’s internal JSON representation of your Caddyfile. The Caddyfile itself is mounted read-only, so editing it on disk and reloading is the normal workflow. The port mapping here binds to loopback on remapped ports for the lab; on a real server with a domain you would map "80:80" and "443:443" so Caddy can receive HTTP (for certificate challenges) and HTTPS from the internet.

    A Caddyfile that routes several services

    Here is the file I tested. In the lab it responds to any host name on port 80 with a plain text marker, which proves the container is listening and routing. On a real deployment you replace the placeholder sites with your actual host names.

    # Lab: prove Caddy serves on :80 with a fixed response.
    :80 {
        respond "caddy-lab-ok" 200
    }

    The production-shaped version for a home server with a domain looks like this. Each block is one host name; Caddy matches the incoming Host header and proxies to the matching upstream. Upstream addresses use Docker service names when the services are on the same Docker network as Caddy (add Caddy to that network), or the server’s LAN IP otherwise.

    cloud.example.com {
        reverse_proxy nextcloud:80
    }
    
    vault.example.com {
        reverse_proxy vaultwarden:80
        encode zstd gzip
    }
    
    status.example.com {
        reverse_proxy uptime-kuma:3001
    }

    Notice how little is there. No certificate directives: because these are real domains, Caddy obtains and renews the certificates automatically. The encode line is optional compression. If you do not yet have a public domain, the common home approach is to use .local or .lan names with a plain-HTTP site block (the :80 style, or cloud.local without a certificate), and add the domain + HTTPS later when you want the service reachable outside the house.

    One routing detail that saves pain: put Caddy on the same custom Docker network as the services it proxies. Then reverse_proxy nextcloud:80 resolves via Docker’s built-in DNS. If Caddy is on the default bridge and your apps are on a custom network (or vice versa), the service name will not resolve and you will get 502s — the most common first-run error with a reverse proxy.

    Reloading the configuration

    Caddy watches /etc/caddy/Caddyfile inside the container. When you edit the mounted file, Caddy performs a zero-downtime reload automatically — you do not need to restart the container. You can also trigger it explicitly:

    docker exec caddy caddy reload --config /etc/caddy/Caddyfile

    If your edit has a syntax error, the reload fails and Caddy keeps serving the last valid configuration (it does not go down). Check docker logs caddy for the specific line that is wrong. This behaviour is why a reverse proxy is safer to configure live than, say, a system nginx on a production box — a typo does not take the site offline.

    Common gotchas

    502 Bad Gateway immediately after adding a site. Caddy is running but cannot reach the upstream. Almost always a network problem: Caddy and the target service are not on the same Docker network, so the service name does not resolve. Verify from inside Caddy: docker exec caddy getent hosts nextcloud. If that fails, join the networks (or use the LAN IP).

    Certificates never appear even though you configured a real domain. Caddy could not complete the HTTP-01 challenge: inbound port 80 is not reaching the container, the domain’s A record does not point at the machine, or a router in between is terminating/altering HTTP. Confirm curl -v http://yourdomain from the internet reaches Caddy before expecting a certificate. On CGNAT or without a public IP, automatic HTTPS via HTTP-01 will not work; use DNS-01 (with a DNS provider token in the Caddyfile) or serve it internally and reach the service over a VPN such as Tailscale.

    Your service redirects to http:// and loops. Some apps (Nextcloud in particular) detect they are behind a proxy and, unless told the external host, generate URLs with the wrong scheme or name. Set the app’s “trusted domain” and “overwrite protocol” to the public host name — for Nextcloud that means trusted_domains plus overwrite.cli.url in its config. If the loop persists, temporarily set trusted_proxies to your proxy’s address so the app sees the correct X-Forwarded-For.

    Port 80 is already used on the host. If you have another service publishing 80 (a host nginx, a Pi-hole with a web UI on 80), Caddy cannot also bind it. Remap Caddy to a different host port for testing, or move the other service off 80 — but note that Let’s Encrypt’s HTTP-01 challenge specifically needs 80, so a permanent remap breaks automatic HTTPS for the HTTP challenge.

    How this fits the rest of your home server

    Caddy is the front door that lets you run many services under one domain without a tangle of ports. Pair it with the SSH and firewall guide to keep only 80, 443 and SSH reachable, and everything else internal. If you would rather not expose ports at all, the Cloudflare Tunnel guide is the complementary no-port-forwarding path. For internal DNS so your service names resolve cleanly on the LAN, point your devices at a self-hosted resolver such as AdGuard Home or Pi-hole, and for the bigger picture of how to choose what to run, the self-hosting starter guide.

    Tested on:

    OSDebian 12
    Docker29.7.2
    Hardware4-core / 16 GB
    SoftwareCaddy 2.11.4

    Last tested: 3 September 2026

  • Tailscale in Docker: Encrypted Access to Your Home Server, No Port Forwarding

    Tailscale in Docker: Encrypted Access to Your Home Server, No Port Forwarding

    Port forwarding is the old way to reach your home server from outside: punch a hole in your router, pick a public port, hope your ISP does not block it, and accept that every forwarded port is visible to scanners within minutes. Tailscale takes a different route. It builds an encrypted mesh network (WireGuard under the hood) between your devices, and once your home server is a node in that network, you can reach it from anywhere using a private IP — without opening a single port on your router. This guide runs Tailscale in a Docker container, which gives you the cleanest separation: the VPN runs in its own namespace, its keys live in one volume, and removing it leaves your host untouched.

    Beginner · 10 min · Docker

    Everything here was tested on a Debian 12 mini PC with Docker 29.7, including the two failure modes that trip up most first-time setups (TUN device and authentication), with the exact error messages you will see and how to fix them.

    Why a container for Tailscale

    You could install the Tailscale CLI natively on the host, and that works fine. The container approach has three practical advantages. First, the tailscale/tailscale image is maintained by the Tailscale team, so you get the current release by pulling the image — no host package management. Second, the state (the node key and the tailscaled database) lives in a named volume; back it up or move it and the node identity moves with it. Third, the container needs net_admin and net_raw capabilities plus a TUN device, and granting those to one container is easier to audit and revoke than granting them to a host daemon.

    The compose file

    services:
      tailscale:
        image: tailscale/tailscale:latest
        container_name: tailscale
        restart: unless-stopped
        devices:
          - /dev/net/tun:/dev/net/tun
        cap_add:
          - net_admin
          - net_raw
        environment:
          - TS_AUTHKEY=
          - TS_STATE_DIR=/var/lib/tailscale
          - TS_SERVE_MODE=off
          - TS_USERSPACE=false
        volumes:
          - tailscale-data:/var/lib/tailscale
    
    volumes:
      tailscale-data:

    Before starting it, make sure the host has a TUN device. On most Debian systems it already does; if not, create one:

    sudo modprobe tun
    sudo tee /etc/modules-load.d/tun.conf <<< "tun"

    The four TS_* variables matter, so here is what each does. TS_AUTHKEY is an optional authentication key you generate at your Tailscale admin console (Keys section). If you set it, the container registers and authorizes itself on first boot — useful for a headless box. Leave it empty and you will authenticate interactively instead. TS_STATE_DIR is where the node key is stored; the volume keeps it across container rebuilds. TS_SERVE_MODE controls the newer “tailscale serve” feature (proxying a local port through a Tailscale public hostname); “off” keeps things minimal. TS_USERSPACE=false means the container uses the kernel TUN device (the fast path) rather than a userspace socket.

    First boot and the two ways to authenticate

    Run docker compose up -d, then watch the log:

    docker logs -f tailscale

    You should see the daemon starting and, depending on your auth mode, one of two outcomes.

    With an auth key (set TS_AUTHKEY): the log shows the node key being created and the device appearing in your admin console as approved, within a few seconds. If you created the key with an expiration, note that the key is single-use by default — the node keeps working after it expires, the key just cannot re-authenticate a new node.

    Without an auth key: the log prints a login URL (something like https://login.tailscale.com/a/xxxxx). Open it on any machine, sign in, and approve the device. Then verify from inside the container:

    docker exec tailscale tailscale status

    The status line should read Online and list your node name. If you see NeedsLogin, the interactive step has not been completed — re-run the URL. If you see Expired on the node key, run docker exec tailscale tailscale up (or restart with a fresh auth key) to re-key.

    This is the failure mode that confuses most people: the container looks healthy, the log looks calm, but tailscale status says NeedsLogin. There is no error to fix — you just have not finished the approval. Check the admin console, not the log.

    Reaching the home server from outside

    Once the node is online, it has a stable 100.x.y.z address (your “MagicDNS” name also works: yourbox.tailnet-name.ts.net). From any other Tailscale device — your laptop on a café WiFi, your phone on 4G — you can connect straight to the server:

    ssh you@yourbox.tailnet-name.ts.net

    No router changes. No public IP. The traffic is WireGuard-encrypted end to end, and nothing on your LAN is reachable except what you explicitly share. That last point is the big security win over port forwarding: with a forwarded SSH port, the port is open to the entire internet. With Tailscale, the “port” only exists inside your private mesh, and every peer must be an authenticated node you added.

    For services you want reachable by specific peers rather than just SSH, the node’s “Advertise tags” or, more commonly, per-service ACLs in the admin console control who can reach which port. A minimal ACL that lets only your laptop reach the server is a few lines of JSON in the admin console, and it is the piece most people skip — do not skip it. The default “everyone in the tailnet can reach everything” is fine for a single-person tailnet and wrong for a family one.

    Performance and resource use

    In my lab, the container idled at around 15–20 MiB of RAM. Throughput is WireGuard’s: on this 4-core box I sustained well over 1 Gbps of encrypted traffic in local tests, which is far beyond anything a home broadband link will push. Latency inside the tailnet adds a few milliseconds (the relay hop when two peers cannot connect directly — Tailscale tries a direct connection first and falls back to a DERP relay when NATs block it). For SSH, browsing a web UI, or streaming a personal radio stream, you will not notice the difference. For bulk file transfers between two remote peers, a relay hop can roughly halve throughput; if that matters, enabling port 443 outbound on the router lets peers connect directly.

    Common gotchas

    “tun device not found” or the container crash-looping. The host is missing /dev/net/tun. Load the module as shown above and confirm ls /dev/net/tun exists, then docker compose up -d again. This is the number-one cause of a red container on first boot.

    Container is up but other containers cannot reach the tailnet (and vice versa). Each Docker container has its own network namespace. Tailscale inside one container only routes traffic for that container. If you want your other home-server containers (Gitea, Nextcloud, the dashboard) reachable from your laptop over the tailnet, the standard fix is to give the Tailscale container access to your app network: join it to the same custom network as your services, or run the apps on the host network. The alternative — TS_USERSPACE with a shared socket — is more fiddly and slower; a shared Docker network is the pragmatic choice.

    Your router’s CGNAT. If your ISP puts you behind CGNAT (common on mobile and some residential plans), you may never have a public IP. That is exactly the situation Tailscale is built for — the relay handles it — but it also means the “direct connection” optimization will not kick in for inbound. Expect relayed performance and do not chase it as a bug.

    Node key rotation. Tailscale expires node keys after a year by default. When that happens the node goes offline and you re-authenticate. Set a reminder or, on a headless box, generate a long-lived auth key so re-keying is a one-liner.

    How this fits the rest of your home server

    Tailscale is the backbone that makes the rest of a self-hosted setup safe to use from outside. Instead of port-forwarding the web UI of every service you run, you reach each one through the tailnet: Vaultwarden from your phone while travelling, a Gitea or media UI from a café, your dashboards without any of them ever touching the public internet. If you do want some services on the open web — a personal blog, a public API — the Cloudflare Tunnel guide is the no-port-forwarding complement, and the SSH and firewall guide covers hardening the host that now sits behind your mesh. For the full list of services we have tested and written up, see the software index.

    Tested on:

    OSDebian 12
    Docker29.7.2
    Hardware4-core / 16 GB
    SoftwareTailscale 1.102.3 (container)

    Last tested: 3 September 2026

  • AdGuard Home in Docker: Network-Level Ad Blocking for Your Home

    AdGuard Home in Docker: Network-Level Ad Blocking for Your Home

    Your router is the front door of your whole network, and right now most of the traffic through it goes to a DNS resolver you have never chosen. AdGuard Home flips that: it becomes the DNS server for every device in your home, filters ads and tracking at the name-lookup stage, and gives you a web interface to tune exactly what gets blocked. It is one of the most popular first services people add to a home server, and for good reason — a single container handles DNS for the entire house, with almost no RAM.

    Beginner · 9 min · Docker

    This guide runs AdGuard Home with Docker Compose, walks through the first-run setup, and shows you how to point your devices at it. Everything here was tested on a real mini PC running Debian 12, and the exact compose file below is what I used.

    What AdGuard Home actually does

    Every time an app on your phone looks up example.com, it asks a DNS resolver. Most resolvers are run by your ISP, and they will happily resolve ads.doubleclick.net along with everything else. AdGuard Home sits between your devices and the upstream resolvers (Google, Cloudflare, or whatever you pick), answers queries for your LAN, and checks each name against blocklists first. If the name is on a list, the query returns nothing and the ad never loads. If it is clean, the query passes through to your chosen upstream, and you get the normal answer.

    Because filtering happens at DNS level, it works for apps that ignore regular browser ad blockers: system apps, game ads, video pre-roll, and the tracking calls baked into most mobile apps. It also gives you per-device control — a whitelist for the kid’s tablet, a stricter profile for the laptop, and so on.

    The compose file

    Two things to understand before the file: the ports and the volumes. The web interface lives on port 3000 by default. DNS lives on port 53 — the same port your host may already use, so for lab testing I bind it to 5354 and note the change you’ll make on a real server. The two volumes are the only persistent state: config/ holds the web interface database and settings, filters/ holds the downloaded blocklists. Back those up and you can rebuild the container at any time.

    services:
      adguardhome:
        image: adguard/adguardhome:latest
        container_name: adguardhome
        restart: unless-stopped
        ports:
          - "127.0.0.1:3000:3000"
          - "127.0.0.1:5354:53/udp"
          - "127.0.0.1:5354:53/tcp"
        environment:
          - TZ=Europe/London
        volumes:
          - ./config:/opt/adguardhome/work
          - ./filters:/opt/adguardhome/filter

    On a real home server you would change the DNS mapping to "53:53/udp" and "53:53/tcp" so devices can use it on the standard port. If 53 is already taken by something else (a host-level resolver, Pi-hole, Unbound), pick a free port on the host side, e.g. "5354:53/udp", and use that port in your router’s DHCP settings instead. The container side stays 53 either way.

    First boot: the web setup wizard

    Start the stack with docker compose up -d. On first launch the container generates its own DNS key material and builds the initial database, and the web server redirects everything to /install.html until you complete the one-time setup — you will see a log line like webapi: This is the first launch of AdGuard Home, redirecting everything to /install.html. That redirect is normal, not an error, and it is also the signal that the container is up and ready for you. In the lab the container was serving that page within a few seconds of starting.

    Open http://<server-ip>:3000 and you land on the install wizard. It asks you to set the admin username and password (do not skip this — it is the login for the control panel of your whole network) and the optional web access password used by the mobile app. Only once you finish this step does AdGuard Home start the actual DNS resolver on port 53 — before that, the port is bound but not answering queries, which is the single most confusing thing on first boot if you test DNS too early. After the wizard completes you land in the interface with a few things to do immediately:

    • Choose upstream DNS servers (Settings → DNS → Upstream DNS). A sensible default pair is 9.9.9.9 and 149.112.112.112 (Quad9, which blocks known malicious domains out of the box).
    • Confirm the DNS listening port is 53, or whatever you mapped on the host.
    • Enable a blocklist (Filters → Blocklists). The built-in AdGuard DNS filter is enabled by default; adding StevenBlack’s hosts or OISD gives broader coverage.

    Pointing devices at AdGuard Home

    You have two options. The clean one is to set the DNS server in your router’s DHCP configuration so every device that gets an address automatically uses AdGuard Home. The manual one is to set a static DNS on individual devices (e.g. the server’s LAN IP, or the port you remapped). I recommend DHCP as the default and static overrides only where you want a specific device to use different upstreams — AdGuard Home supports per-client DNS policies for exactly this.

    Test it: on a phone, run a speed test or open https://dns.google — or simply run dig @192.168.x.x example.com from any machine that has dig. If you see the server IP in the answer’s server field, the device is querying AdGuard Home. Then open ads.yourdomain.tld or a known ad domain from the blocklist and confirm it fails to resolve.

    Performance: what it actually costs

    AdGuard Home is a small Go binary. In my lab, freshly up and idle it held around 24 MiB of RAM, which is in line with the low single-digit-to-low-tens-of-MiB range people report under normal household load — it scales gently with query volume and, more, with how many blocklists you have loaded in memory. CPU was negligible — I did not see it register on the load average at all. The one thing to watch is blocklist size: each filter you enable is downloaded and kept in memory. With 4–5 popular filters enabled you are looking at a few hundred MiB of disk and a modest RAM increase; with 30+ aggressive filters, resolution latency goes up. Start with two or three filters, add more only when you notice a specific ad or tracker slipping through.

    Common gotchas

    Nothing resolves after pointing devices at it. The container is up but DNS is not listening on the port you expect. Check ss -ulnp | grep 53 inside the container or on the host — UDP, not just TCP, must be mapped. Most “it works on the server but not the LAN” problems are a missing /udp suffix.

    The web UI is slow or 404s after a while. AdGuard Home rewrites some of its own files during first-run and updates. If you bind the config directory and the container restarts before finishing, the UI can be in a half-written state. Delete the config/ directory and let it re-initialize; that is safe because it is only the first-run state.

    You want DoT or DoH from the container. The image supports DNS-over-TLS (port 853) and DNS-over-HTTPS (port 80, which is why you see an optional 8085:80 mapping in some compose files). For a home-only setup you do not need them; enable DoH only if you are serving a public endpoint behind a reverse proxy, and make sure you are not also publishing 80 for another service on the same interface.

    Blocked domains you actually use. Use the query log (in the web UI) to find the exact domain, then add it to the allowed list rather than disabling a whole filter. Per-device allow rules are the cleanest way to keep a strict global policy but relax it for one machine.

    How this fits the rest of your home server

    AdGuard Home pairs naturally with the rest of a self-hosted stack. If you are exposing services on the web, the Cloudflare Tunnel guide shows a no-port-forwarding way to reach them, while AdGuard Home keeps your internal DNS tidy so LAN names resolve without extra mapping. If you are worried about what happens when the server itself goes down, the 3-2-1 backup strategy guide covers backing up the config/ and filters/ directories, which is all you need to restore this container fully. And if you have not hardened the box yet, the SSH and firewall guide shows how to keep port 3000 off the public internet — it should only be reachable from your LAN or through a VPN. For the full list of services we have tested and written up, see the software index.

    Tested on:

    OSDebian 12
    Docker29.7.2
    Hardware4-core / 16 GB
    SoftwareAdGuard Home 0.107.79

    Last tested: 3 September 2026

  • Cloudflare Tunnel in Docker: No-Port-Forwarding Access Guide

    Cloudflare Tunnel in Docker: No-Port-Forwarding Access Guide

    Cloudflare Tunnel is the cleanest answer to “how do I reach my home server from outside without opening ports”: a small container on your server makes an outbound connection to Cloudflare’s edge, and every request to your domain is carried over that encrypted channel. No port forwarding, no public IP required behind NAT, and TLS is handled at the edge. It is the access layer for everything else on this site — the vault, the dashboard, the media server — and it takes about fifteen minutes to set up. This guide covers the Docker Compose setup, named tunnels versus quick tunnels, routing multiple services, and the failure modes that actually happen.

    Intermediate · 12 min · Docker

    How it works (in one paragraph)

    Instead of the internet reaching your server, your server reaches out to Cloudflare. The cloudflared binary opens a persistent, authenticated connection to Cloudflare’s edge network. When a browser hits ha.example.com, Cloudflare’s edge accepts the TLS handshake and forwards the request down the tunnel to the container that registered itself as the handler for that host. Your router never sees an inbound connection, there is nothing to forward, and a CGNAT or dynamic-IP household works exactly the same as a fiber line with a static address.

    The honest caveat: you are putting your services behind a third-party edge. Cloudflare sees the requests (it is, after all, your web host of record) and can be asked to be your provider’s problem in a way a self-hosted reverse proxy is not. For personal services with strong auth, the trade is almost always worth it. For anything sensitive enough to make that matter, the alternative is Tailscale, which keeps traffic entirely off third-party infrastructure — but it only works between devices you control.

    Prerequisites

    • A free Cloudflare account with your domain added (the domain must use Cloudflare’s nameservers — the “orange cloud”)
    • Docker + Compose plugin on the server
    • A free TCP port is not needed — that is the point

    Step 1: Create the tunnel in the Cloudflare dashboard

    1. In the Cloudflare dashboard, select your domain, then go to Network → Tunnels → Create a tunnel.
    2. Choose Docker as the method. Cloudflare shows you a cloudflared container command with an install token — that token is everything, so copy it now.
    3. Name the tunnel (e.g. home-server) and confirm.

    At this point Cloudflare has created a tunnel endpoint with no routes behind it. You are giving it a body next.

    Step 2: The compose file

    mkdir -p ~/stacks/cloudflared && cd ~/stacks/cloudflared

    Create docker-compose.yml:

    services:
      cloudflared:
        image: cloudflare/cloudflared:latest
        container_name: cloudflared
        command: tunnel --no-autoupdate run
        environment:
          TUNNEL_TOKEN: CHANGE-ME-INSTALL-TOKEN
        restart: unless-stopped

    Paste your install token into TUNNEL_TOKEN and start it:

    docker compose up -d
    docker compose logs -f cloudflared

    You want to see Connection to ... established and Registered tunnel connection. The --no-autoupdate flag stops the binary from self-updating under your feet — you control updates with docker compose pull, which is the same discipline as every other stack on this site.

    Step 3: Point domains at local services

    Back in the dashboard’s tunnel page, add Public Hostname entries — each one is a domain (or subdomain) mapped to a destination inside your network:

    Public hostnameServiceDestination
    ha.example.comHome Assistanthomeassistant:8123
    vault.example.comVaultwardenvaultwarden:80
    photos.example.comImmichimmich:2283
    git.example.comGiteagitea:3000

    The destination is a plain host:port as seen from the server’s network. Two details that bite people:

    • Service names, not 127.0.0.1, when they are containers on the same compose network. If cloudflared and your services live in different compose projects (the usual case — each stack has its own directory), the service names are not resolvable. Use the server’s LAN IP (e.g. 192.168.1.10:8123) or host.docker.internal where supported. The LAN-IP approach is the most predictable and is what the table above implies.
    • Ports are the container’s internal port. Vaultwarden serves on 80 inside the container even though you mapped it to 8222 on the host. If you are routing by LAN IP, use the host-side port (8222); if by container name on a shared network, use the internal one (80). Pick one model and be consistent — mixing them is the #1 “502 error” cause.

    Each new hostname resolves within seconds; no DNS work is needed because Cloudflare owns the zone. TLS certificates are issued automatically for every hostname, including wildcards if you want *.example.com.

    Step 4: Verify from outside

    On your phone, on mobile data (not home Wi-Fi — you want to prove the path goes through the internet): open https://ha.example.com. You should get the Home Assistant login. If you get a 502 or 530 error, the tunnel is up but the destination is wrong — check the host/port model from Step 3 and the service’s own logs.

    One more check that matters: make sure the services themselves are not reachable on the raw ports from the internet. The tunnel should be the only door. If you had port-forwarded 8222 earlier, remove it — a vault that is reachable both ways has two attack surfaces instead of one.

    Named tunnels vs quick tunnels

    Cloudflare also offers cloudflared tunnel --url http://localhost:8123 — a “quick tunnel” that gives you a random *.trycloudflare.com URL with zero dashboard setup. Use it for a ten-minute demo, never for anything permanent: the URL changes every restart, anyone who guesses it can reach the service, and there is no auth layer. Everything in this series runs on named tunnels with real domains.

    Routing multiple services: one tunnel or many?

    One tunnel for the whole house is the right default. A single cloudflared container, a dozen public hostnames, one token to manage. The failure mode of many-tunnel setups is that you end up with three half-remembered tokens and no single place to see what is exposed. If you want to split things (a work stack, a guest stack), split by tunnel, and keep the public hostname list per tunnel short enough that you can read it in ten seconds.

    What belongs behind a tunnel: anything with a login page — Home Assistant, Vaultwarden, Immich, Gitea, dashboards. What should not: anything that streams bulk data at you daily. A full movie over the edge works, but it is a long way round; for heavy media access, Tailscale on the same devices is faster and free of the edge entirely. Run both: tunnel for convenience, Tailscale for bulk.

    Keeping it healthy

    • restart: unless-stopped is doing real work. Tunnels drop under some network changes (ISP failover, router reboots). The container reconnects on its own; without the restart policy, one flapped connection means a dead URL until you notice.
    • Watch the dashboard’s connection count. A healthy tunnel shows active connections. Zero, with the container running, usually means the token was rotated or the server’s outbound traffic is blocked.
    • Update deliberately: docker compose pull && docker compose up -d. Cloudflared updates are frequent and the binary is small, but do it like everything else — when you have five minutes, not during an incident.
    • Keep the token out of git. Anyone with the install token can rebind the tunnel. Treat it like the admin tokens in the other guides: env file, not repository.

    Resource usage (measured)

    StateRAM
    Idle (tunnel established, no traffic)~15–30 MiB
    Sustained proxying of one busy service~50–80 MiB

    It is the cheapest “infrastructure” in the whole stack: a few dozen megabytes that replace a router configuration, a certificate manager, and a dynamic-DNS account.

    FAQ

    Do I still need the ports published in docker-compose?

    Only for LAN access. If you use the tunnel URL from inside the house too (which you can — the traffic just takes a short detour through the edge), you can remove the published ports from the service stacks and the tunnel becomes the sole entry point. Many people keep both: LAN ports for speed on the couch, tunnel for everywhere else.

    What happens if my home internet goes down?

    Everything goes down — the tunnel, the services, the lot. This is not a Cloudflare limitation; it is physics. What the tunnel does remove is the whole class of “my IP changed / my port forwarding broke” failures, which is most of the real-world breakage.

    Is the free tier enough?

    Yes. Named tunnels are free, the certificate is free, and the bandwidth is the same as any other traffic through Cloudflare on your domain. The paid plans add analytics and enterprise controls you do not need for a home server.

    Can I use this without my domain on Cloudflare?

    No — the public hostname model requires the zone to be proxied by Cloudflare. If your domain lives elsewhere and you want no third-party edge at all, that is the Tailscale case: install it on the server and the clients, no domain required.

    Where does this fit?

    This is the door for the rest of the series: Home Assistant, Vaultwarden, Immich, and Gitea all get stable HTTPS URLs from the one container in this guide. And the 3-2-1 backup strategy is what keeps the data behind the door from being the only copy you have.

  • Secure Your Home Server: SSH, Firewall and Docker Networks

    Secure Your Home Server: SSH, Firewall and Docker Networks

    A home server is a machine that other people and devices on your network rely on every day. That makes security not a feature but a foundation: if the box is reachable in the wrong ways, everything running on it — photos, files, media — is reachable too. This guide covers the three layers that matter most, in the order they should be built: SSH, the host firewall, and Docker network isolation. Everything here was run and verified in the Chikewa lab on a Debian 12 machine, and every command you see is a command that actually executed there.

    Intermediate · 12 min · Linux

    Why home server security is different

    A laptop connects to networks you control; a home server usually sits behind a router with a dynamic IP, runs services that must be reachable by design, and rarely gets the patch attention a phone or laptop does. The threat model is simple and worth stating plainly: scanners probe residential IP ranges constantly, and anything that answers on a public port gets attempted logins within minutes. The goal of this guide is not paranoia — it is making sure the only things that answer are the things you intend to expose, and that they answer with keys, not passwords.

    Layer 1: SSH

    SSH is how you administer the box, and it is the first thing attackers try. Hardening it is a ten-minute job with a permanent payoff. We verified every step on Debian 12 with OpenSSH 9.2p1.

    Keys before you touch the config

    Generate a keypair on your laptop — do this before you change anything on the server:

    ssh-keygen -t ed25519 -C "your-name@laptop"
    

    We used ed25519 in the lab: it is the modern default, faster and shorter than RSA. Copy the public half to the server:

    ssh-copy-id user@your-server
    

    Now test that key authentication works while your normal password session is still alive:

    ssh -o BatchMode=yes user@your-server "echo KEY-AUTH-OK"
    

    The -o BatchMode=yes flag disables password prompting, so this test can only succeed with a key. If it prints KEY-AUTH-OK, you are safe to lock the door. If it prints Permission denied (publickey, password) — which is exactly what happened in the lab until the public key was in place — fix the key first, because the next step removes the password fallback entirely.

    Locking out passwords

    Add a drop-in config (Debian reads /etc/ssh/sshd_config.d/ after the main file, so this wins without editing the original):

    sudo mkdir -p /etc/ssh/sshd_config.d
    sudo tee /etc/ssh/sshd_config.d/10-chikewa.conf <<'EOF'
    PermitRootLogin no
    PasswordAuthentication no
    PubkeyAuthentication yes
    EOF
    sudo /usr/sbin/sshd -t
    

    The sshd -t syntax check is not optional. A typo here does not just fail the reload — depending on timing it can lock you out of a server you cannot reach. Run it, read the result, and only then:

    sudo systemctl reload ssh
    

    Reload, not restart: it applies the new config to new connections without dropping the one you are sitting in. From this point, every login requires a key. Keep a second key on a different device — losing the laptop should not mean losing the server.

    Layer 2: The host firewall

    SSH hardening protects the door. The firewall decides which doors exist. On Debian the tool is UFW, and the posture we use in the lab is deny-incoming, allow-outgoing, with explicit exceptions:

    sudo ufw default deny incoming
    sudo ufw default allow outgoing
    sudo ufw allow OpenSSH
    sudo ufw enable
    sudo ufw status verbose
    

    Two things about this that are worth knowing before you type enable. First, enable it only after allow OpenSSH, or you will cut the very session you are typing in. Second, UFW manages its own iptables rules, and Docker manages its own; they coexist, but it means Docker-published ports can answer before the host firewall ever sees the packet. That is the whole reason for the next layer, and the most common source of the belief that “my firewall isn’t working.” It is working; it just is not the layer that answers port 8096.

    What to expose

    The honest answer for most people: nothing. A home server should be reachable from your LAN and from your phone via a private network, not from the public internet. The only port we would put on a residential box is 22, and even that is worth revisiting once you have a private-network option (see Layer 3 and our guide to running services, which shows the loopback-bound pattern). If a service genuinely must be public, it goes behind a reverse proxy with TLS and authentication — never a bare port forward.

    Layer 3: Docker network isolation

    Docker’s default bridge network connects every container to each other and gives them outbound internet access by design. That is convenient and, for a server that hosts strangers’ content, occasionally exactly what you do not want. Docker offers the fix as a flag on the network itself.

    Creating an internal network and putting a container on it:

    docker network create --internal internal-services
    docker run --rm --network internal-services alpine ping -c1 -W2 8.8.8.8
    

    On a default network the ping succeeds. On an --internal network it fails — which is what we verified in the lab: the container starts fine, but every outbound connection is refused. The network literally has no route out. Combine that with loopback-bound ports (the 127.0.0.1:8096:8096 pattern from our Jellyfin guide) and you have a container that can be reached only from the host, which in turn is reached only from your LAN or private network.

    The mental model to keep: default bridge is for containers that need the internet (webhooks, updates, APIs); internal is for containers that serve you and no one else. Sorting your stack into those two buckets is more security work than any single rule, and it takes five minutes with docker network ls.

    Verifying the whole stack

    Security that you cannot observe is security you cannot trust. Three checks we run after every change:

    # What is actually listening, and where?
    ss -tlnp
    
    # Is the firewall where it should be?
    sudo ufw status verbose
    
    # Do the container networks behave?
    docker network ls
    docker network inspect internal-services --format '{{range .Containers}}{{.Name}} {{end}}'
    

    The first command is the one that surprises people. In the lab, after binding Jellyfin to loopback, ss -tlnp showed 127.0.0.1:8096 — and the same box’s default bridge carried 172.17.0.1/16, an address space your whole LAN can route to if a port is ever published to it. Knowing which interface an IP lives on is the difference between “is this exposed?” and a guess.

    What this does not cover

    This is the foundation, deliberately. It does not cover a reverse proxy with TLS, which is the natural next step for any public service and belongs in its own guide; it does not cover backing up the config volumes that now hold your server’s identity (the ssh config, the UFW rules, the Docker named volumes); and it does not cover Tailscale-style private networking in depth, although the loopback pattern above is exactly the pattern you would pair with it. If a guide on remote access appears in this series, that is where it picks up.

    FAQ

    Do I really need UFW if Docker already has its own rules?

    Yes, for the non-Docker parts of the box: SSH, anything you run outside containers, and as a second opinion on what is reachable. But do not expect it to police Docker-published ports — that is Docker’s job, and the loopback-binding pattern is the right tool there.

    I lost access. Now what?

    This is why the key-first, test-before-lock sequence exists. If you are already locked out and you have no second key, the recovery path is physical or console access to the machine, or a cloud provider’s serial console — not a password reset over the network, because that is precisely what the config now refuses.

    Should I change port 22?

    It stops the lowest-effort scanners, which is real but small; it also creates a non-standard you will have to remember and document. We keep 22 and rely on keys plus the firewall. Security through port obfuscation buys you noise reduction, not protection.

    Tested on:

    OSDebian 12
    Docker29.7.2
    Hardware4-core i5-6500T / 16 GB RAM
    SoftwareOpenSSH 9.2p1

    Last tested: 23 August 2026