Tag: Security

Practical home server security: SSH hardening, firewalls, network isolation and the settings that matter most.

  • 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

  • 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.

  • Vaultwarden in Docker: Self-Hosted Bitwarden Password Manager

    Vaultwarden in Docker: Self-Hosted Bitwarden Password Manager

    Vaultwarden is a self-hosted server compatible with the Bitwarden client apps: your passwords, notes, cards and identities live in an encrypted database you control, and every official Bitwarden app — browser extension, desktop, mobile — connects to it unchanged. It runs in a single container with a tiny footprint, which makes it one of the cheapest “replace a SaaS” moves in self-hosting. This guide covers the Docker Compose setup, connecting the apps, the admin panel, and the backup habit that matters most.

    Beginner · 8 min · Docker

    What you are actually getting

    • Bitwarden-compatible server. Vaultwarden implements the Bitwarden server API. Clients do not know or care that it is not bitwarden.com — you just change the server URL.
    • Zero-knowledge encryption. Vault and items are encrypted on your device before they reach the server. The server stores ciphertext. A copy of the database on a NAS is useless to anyone who does not have your master password.
    • One container, one folder. No database server to babysit: state (SQLite) and attachments live in a single /data volume.

    The honest caveat: Vaultwarden is a community project maintained by volunteers. It is the de-facto standard for self-hosted Bitwarden, but it is not Bitwarden’s own enterprise server — you give up official enterprise features (SSO/SAML, fine-grained org policies) in exchange for running on a Pi.

    Prerequisites

    • Docker + Compose plugin
    • A free TCP port (this guide uses 8222; the container serves on 80 internally)
    • Strong credentials — this is the thing that guards every other login you have

    Step 1: The compose file

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

    Create docker-compose.yml:

    services:
      vaultwarden:
        image: vaultwarden/server:latest
        container_name: vaultwarden
        ports:
          - "8222:80"
        environment:
          DOMAIN: "https://vault.example.com"
          SIGNUPS_ALLOWED: "true"
          # After the first account exists, flip this to "false"
          ADMIN_TOKEN: CHANGE-ME-64-CHARS-HEX
        volumes:
          - ./data:/data
        restart: unless-stopped

    Notes on the three environment variables:

    • DOMAIN — the public URL clients will use. Set it to your final TLS URL before connecting apps, because some app flows (email, OAuth-style links) embed it. If you are LAN-only for now, http://YOUR_SERVER_IP:8222 works, but update it when you add TLS.
    • SIGNUPS_ALLOWED — leave true only long enough to create your account, then set it to false and restart. Open signups on a public URL is how people end up with stranger accounts in their vault server.
    • ADMIN_TOKEN — required to open the admin panel (at /admin). Generate it with openssl rand -hex 32. It is a master key to the admin interface: treat it like a password and keep it out of git.

    Step 2: Start it and create your account

    docker compose up -d
    docker compose logs -f vaultwarden

    Open http://YOUR_SERVER_IP:8222 and register. Your first account is an administrator of the implicit personal organization. Set a very strong master password here — it is the only key that decrypts your vault, and there is no “forgot password” that works the way you hope. Write it down offline.

    Immediately after: set SIGNUPS_ALLOWED: "false" and docker compose up -d again.

    Step 3: Connect the official Bitwarden apps

    This is the part that makes Vaultwarden feel free: every official Bitwarden client works against it.

    1. Browser extension (Firefox/Chrome/Edge/Safari): in the extension settings, set Self-hosted and enter the server URL (http://YOUR_SERVER_IP:8222 or your TLS URL). Log in with your Vaultwarden account.
    2. Mobile apps (iOS/Android): add a new organization / server, enter the same URL, log in.
    3. Desktop apps: same — set the server URL in settings before logging in.

    Everything syncs between them through your server. The extension autofills, the mobile app has the vault offline, and the desktop app is your admin fallback. You are now running your own password infrastructure with no configuration difference visible in the clients.

    Step 4: The admin panel

    Open http://YOUR_SERVER_IP:8222/admin and paste your ADMIN_TOKEN. Useful things in there:

    • Users — see every account, disable one you no longer recognize.
    • Settings → Global settings — the runtime view of every environment variable, editable without editing the compose file (changes apply on save; the compose values remain the source of truth on restart).
    • System — server version, pending updates, and a one-click update server button.

    Step 5: TLS and external access

    Browsers are increasingly strict about where they will send a master password. For a URL you use daily, put TLS in front — the two clean options from the Security & Networking series:

    • Cloudflare Tunnelhttps://vault.example.com, no open ports, certificates handled. We document it in the Security & Networking series.
    • Reverse proxy with a real certificate — Caddy or Nginx Proxy Manager, same idea, more knobs.

    Then update DOMAIN in the compose file to the HTTPS URL, restart, and point all clients at it. Do not forward port 8222 on the router: a publicly scannable login page for your password vault is precisely what attackers probe first.

    Backups (the part that actually matters)

    Everything is in ./data — the SQLite database, attachments, and (if enabled) the encrypted audit log. A backup is copying that one folder. Because the contents are already encrypted with your master password, you can store the backup anywhere, including the same machine’s offsite target:

    restic -r s3:http://YOUR_MINIO_IP:9000/backups backup ~/stacks/vaultwarden/data

    One habit that saves you: export a full encrypted vault file from the Bitwarden app once a month (Vault → Export → Encrypted JSON) and keep it somewhere the server cannot reach. If the data volume is ever corrupted or lost, that file plus your master password is a complete restore. The server database is the source of truth; the encrypted export is the insurance policy.

    Resource usage (measured)

    StateRAM
    Idle~30–50 MiB
    Syncing a large vault (thousands of items)~80–120 MiB

    It will run on the smallest Pi you own, next to everything else, and you will forget it is there. That is the point.

    Updating

    docker compose pull && docker compose up -d

    Vaultwarden migrations run automatically on start. Check the admin panel’s system page after the first start to confirm the new version. As with any service guarding credentials, a one-line restic backup of ./data before a major update is free insurance.

    FAQ

    Is this secure? It is not Bitwarden Inc’s server.

    The encryption model is the same: zero-knowledge, AES-256, keys never leave your device. The risk profile shifts from “a company holds ciphertext and could be compelled” to “you hold the ciphertext and must not lose your master password”. For most people that is a net improvement. Read the project’s security page for the details, and note it is a community project — audit it if that matters to you.

    Can I migrate from bitwarden.com?

    Yes, in two steps. Export an encrypted JSON from the official app (never the unencrypted CSV to a file you store), then in the Vaultwarden-connected app use Import to bring that encrypted file in. Your items land in your self-hosted vault with their encryption intact.

    What about 2FA?

    Vaultwarden supports TOTP (authenticator apps) and YubiKey/WebAuthn per user, set under your profile. Turn on TOTP the day you create the account. The master password plus TOTP is the floor for a credential vault.

    Can I run it on the same box as everything else?

    Yes — it is one of the lightest services in this whole series. The only requirement is that ./data is included in whatever backup job protects the rest of the stacks.

    Where does this fit?

    Vaultwarden is the credentials layer: it is what you log into for Miniflux, Jellyfin, and every admin panel on this site. Reaching it from outside the house is the tunnel setup from the Security & Networking series, and its data folder is one line in a restic job — the 3-2-1 backup strategy guide walks through that loop.

  • 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