Most home labs grow the same way. You deploy a dashboard, then a file server, then a notes app, and each one gets its own login page, its own password, and often a port opened directly on the router. Each of those logins is an attack surface, and most of them have no lockout policy, no multi-factor authentication, and no logging anyone reads. Within days of exposing a port to the internet, automated scanners will find it and start trying credentials.

This guide shows you how to fix that in a single afternoon. You will put one reverse proxy in front of your apps, route all authentication through a single sign-on (SSO) portal that requires a second factor, and add an intrusion-blocking layer that watches the logs and bans IPs that behave like brute-force tools. The result is that your backend applications are never reachable without passing the login portal first, and repeated failures get the attacker’s address blocked at the firewall.

The guide is for home-lab operators and small self-hosting setups running a handful of web applications on a single Linux host. The tools are Caddy as the reverse proxy, Authelia as the SSO and MFA gateway, and fail2ban for log-driven blocking. Each one is widely used and can be swapped for an equivalent, but the pattern holds regardless of which tools you pick.

Before you start

  • A Linux host (a recent Debian or Ubuntu release works well) with root or sudo access.
  • Docker and the Docker Compose plugin installed.
  • A domain name you control, with DNS records you can edit. You will need auth.YOUR_DOMAIN and one hostname per app, such as app.YOUR_DOMAIN, all pointing at your host’s public IP.
  • Ports 80 and 443 forwarded from your router to the host. Caddy needs port 80 reachable to complete certificate validation.
  • An authenticator app on your phone that supports TOTP (most password managers do).
  • At least one self-hosted app you want to protect. The examples use a placeholder called app listening on port 8080.

Step 1: Lay out the directory and decide what is public

Keep all proxy-related files in one directory so the logs and configuration are easy to find and back up. The layout used throughout this guide is:

sudo mkdir -p /srv/proxy/{authelia,logs/caddy}
cd /srv/proxy

Before writing any configuration, decide the security model. Only the reverse proxy should listen on the public internet. Your applications and the SSO service should be reachable only over an internal Docker network. Nothing else should publish a port. If an app currently binds to a host port, remove that binding once the proxy is in place, or the login gate can be bypassed by connecting directly.

Step 2: Define the container stack

Create /srv/proxy/docker-compose.yml. The key detail is that only Caddy publishes ports. Authelia and the application use expose, which makes them reachable to other containers on the same network but not from outside the host.

services:
  caddy:
    image: caddy:2
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - ./logs/caddy:/var/log/caddy
      - caddy_data:/data
    networks: [proxy]

  authelia:
    image: authelia/authelia:YOUR_PINNED_RELEASE_TAG
    restart: unless-stopped
    volumes:
      - ./authelia:/config
    expose:
      - "9091"
    networks: [proxy]

  app:
    image: YOUR_APP_IMAGE
    restart: unless-stopped
    expose:
      - "8080"
    networks: [proxy]

networks:
  proxy: {}

volumes:
  caddy_data: {}

Replace YOUR_PINNED_RELEASE_TAG with a specific release tag from the Authelia project’s documentation rather than a floating tag. Pinning means upgrades happen when you choose, not when a container pull happens to run.

Step 3: Configure Caddy as the front door

Create /srv/proxy/Caddyfile. Caddy obtains and renews TLS certificates automatically for every hostname it serves. The forward_auth directive sends each request to Authelia before it reaches the app. If Authelia does not approve the session, the user is redirected to the login portal. The global block also writes JSON access logs, which the intrusion-blocking layer will read later.

{
    log {
        output file /var/log/caddy/access.log
        format json
    }
}

auth.YOUR_DOMAIN {
    reverse_proxy authelia:9091
}

app.YOUR_DOMAIN {
    forward_auth authelia:9091 {
        uri /api/authz/forward-auth
        copy_headers Remote-User Remote-Groups Remote-Email Remote-Name
    }
    reverse_proxy app:8080
}

Add one forward_auth block per protected hostname. Notice that the application never handles a login itself. Its only job is to serve content to requests that Caddy has already authorized.

Step 4: Set up Authelia with access rules and TOTP

Authelia stores its configuration in /srv/proxy/authelia/configuration.yml. The important settings are the default deny policy, the rule that requires two factors for your app, and the session cookie scoped to your domain. Secrets should never be written inline. Authelia supports reading them from files via _FILE environment variables, which keeps them out of the compose file and shell history.

server:
  address: 'tcp://:9091'

log:
  level: info
  file_path: /config/authelia.log

totp:
  issuer: YOUR_DOMAIN

authentication_backend:
  file:
    path: /config/users_database.yml

access_control:
  default_policy: deny
  rules:
    - domain: 'app.YOUR_DOMAIN'
      policy: two_factor

session:
  cookies:
    - domain: 'YOUR_DOMAIN'
      authelia_url: 'https://auth.YOUR_DOMAIN'
      expiration: 1h
      inactivity: 15m

storage:
  local:
    path: /config/db.sqlite3

notifier:
  filesystem:
    filename: /config/notification.txt

The default_policy: deny line matters more than anything else in the file. Any hostname you forget to add to the rules is blocked rather than silently open. Generate the secrets as files and point Authelia at them:

cd /srv/proxy/authelia
sudo sh -c 'head -c 64 /dev/urandom | base64 -w0 > session_secret'
sudo sh -c 'head -c 64 /dev/urandom | base64 -w0 > storage_key'
sudo sh -c 'head -c 64 /dev/urandom | base64 -w0 > reset_jwt_secret'
sudo chmod 600 session_secret storage_key reset_jwt_secret

Add the matching environment variables to the authelia service in the compose file, then store the paths so the container can read them:

    environment:
      AUTHELIA_SESSION_SECRET_FILE: /config/session_secret
      AUTHELIA_STORAGE_ENCRYPTION_KEY_FILE: /config/storage_key
      AUTHELIA_IDENTITY_VALIDATION_RESET_PASSWORD_JWT_SECRET_FILE: /config/reset_jwt_secret

Create your user with a hashed password. Generate the hash with the Authelia container itself so the format matches what the service expects:

docker run --rm authelia/authelia:YOUR_PINNED_RELEASE_TAG \
  authelia crypto hash generate argon2 --password 'YOUR_STRONG_PASSPHRASE'

Put the resulting hash into /srv/proxy/authelia/users_database.yml:

users:
  YOUR_USERNAME:
    disabled: false
    displayname: "YOUR_NAME"
    password: "PASTE_ARGON2_HASH_HERE"
    email: you@YOUR_DOMAIN
    groups:
      - admins

Bring the stack up with docker compose up -d. When you first sign in at app.YOUR_DOMAIN, Authelia will walk you through enrolling a TOTP device. Scan the QR code with your authenticator app and enter the six-digit code to confirm. Save the recovery information Authelia gives you somewhere safe. Losing your second factor without a recovery path locks you out of your own lab.

Step 5: Lock down the host firewall

Gating HTTP is only useful if nothing else answers on the public interface. Allow SSH first so you do not lock yourself out, then allow only 80 and 443:

sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw default deny incoming
sudo ufw enable
sudo ufw status verbose

Be aware that Docker writes its own iptables rules for published ports, and those rules can bypass ufw. Because of this, you should confirm that a port you did not intend to publish is actually unreachable from outside your network. Scan from a machine off your LAN after each change, and do not rely on ufw alone for protection.

Step 6: Install fail2ban and write the filters

fail2ban watches log files for patterns you define, counts matches per source IP within a time window, and creates firewall bans when a threshold is crossed. Install it and create two filters, one for failed Authelia logins and one for scanners hitting nonexistent paths through Caddy:

sudo apt install fail2ban
sudo nano /etc/fail2ban/filter.d/authelia.conf
[Definition]
failregex = ^.*Unsuccessful (1FA|TOTP|2FA).*remote_ip="?<HOST>"?
ignoreregex =
sudo nano /etc/fail2ban/filter.d/caddy-scanners.conf
[Definition]
failregex = ^.*\"remote_ip\":\"<HOST>\".*\"status\":404,
ignoreregex =

The exact wording of Authelia’s failure messages can change between releases, so treat the failregex above as a starting point. Before enabling any jail, test the filter against your real logs. This is the step most people skip, and it is the one that prevents banning yourself or banning nothing at all:

sudo fail2ban-regex /srv/proxy/authelia/authelia.log /etc/fail2ban/filter.d/authelia.conf
sudo fail2ban-regex /srv/proxy/logs/caddy/access.log /etc/fail2ban/filter.d/caddy-scanners.conf

If the output shows zero matches after you have triggered a few failed logins, adjust the pattern to match the actual log lines you see. The tool reports how many lines matched and how many were missed, so iterate until the counts make sense.

Step 7: Define the jails and ban action

A jail ties a filter to a log file and a ban policy. Create /etc/fail2ban/jail.local. Put your home network in ignoreip so a mistyped password on your own laptop never locks you out of your lab.

[DEFAULT]
bantime     = 1h
findtime    = 10m
maxretry    = 5
banaction   = nftables-multiport
ignoreip    = 127.0.0.1/8 ::1 192.168.1.0/24

[authelia]
enabled     = true
port        = http,https
filter      = authelia
logpath     = /srv/proxy/authelia/authelia.log
backend     = auto

[caddy-scanners]
enabled     = true
port        = http,https
filter      = caddy-scanners
logpath     = /srv/proxy/logs/caddy/access.log
maxretry    = 30
findtime    = 1m
bantime     = 24h

The Authelia jail uses a short ban because a human might mistype a passphrase, and five failures in ten minutes is a reasonable threshold. The scanner jail tolerates more hits because legitimate browsers request the occasional missing file, but it bans for a day because automated scanners do not stop on their own. Adjust the numbers to your tolerance. Then restart fail2ban and confirm the jails loaded:

sudo systemctl enable --now fail2ban
sudo systemctl restart fail2ban
sudo fail2ban-client status

If Caddy runs in Docker with published ports, traffic is forwarded to the containers through the forward path rather than delivered to the host’s input chain. A ban that only lives in the input chain will not stop it. You can either run Caddy with host networking so the traffic terminates on the host, or adjust the ban action to hook into the forward path as well. Whichever you choose, the verification step below is how you find out whether it works.

Verify it worked

Start with the basic gate. Request a protected hostname without a session. You should get a redirect to the login portal, or a 401 for non-browser clients, and never the application’s content:

curl -sI https://app.YOUR_DOMAIN | head -n 5

Confirm that the certificate is valid and that the auth portal responds:

curl -sI https://auth.YOUR_DOMAIN | head -n 3

Next, confirm that the backend is unreachable from outside. Run a port scan from a machine off your LAN, or use an external port-check service, and confirm that only 80 and 443 respond. Your app’s port should be closed.

Then test the ban logic without attacking yourself. Ban a documentation address from the TEST-NET range, check that it appears, and unban it:

sudo fail2ban-client status authelia
sudo fail2ban-client set authelia banip 203.0.113.50
sudo fail2ban-client status authelia
sudo fail2ban-client set authelia unbanip 203.0.113.50

Finally, test the real path. From a network you control, make several wrong password attempts against the login portal. After the threshold, the source address should be banned at the firewall, and requests from it should time out rather than return a login page. Confirm the block with sudo nft list ruleset, which should show the fail2ban set containing the address, and check the logs with sudo fail2ban-client status authelia. When you are done, unban your test address.

Common mistakes

  • Leaving an app port published. If the application still listens on a host port, anyone can bypass the proxy and its login gate. Check with docker compose ps and an external scan.
  • Trusting fail2ban without testing the regex. A filter that matches nothing gives you false confidence. Always run fail2ban-regex against real logs.
  • Banning yourself. Without an ignoreip entry for your LAN, a few mistyped passwords can lock you out. Keep a local console or SSH path that does not depend on the proxy.
  • Forgetting the default deny. If you change default_policy to allow, or add a new app without a rule, that app may be open or may fail in confusing ways. Keep deny as the default and add rules deliberately.
  • Losing the second factor. Enroll TOTP, save recovery codes, and store them outside the lab. A second factor you cannot reach is indistinguishable from a lockout.
  • Storing secrets in the compose file or shell history. Use the _FILE variables and restrictive file permissions as shown.
  • Using floating image tags. An automatic upgrade can change login behavior or configuration format without warning. Pin releases and upgrade on purpose, reading the changelog first.
  • Assuming the firewall covers Docker. Docker’s rules can bypass ufw and host input chains. Verify from outside the network after every change.

Takeaway

The pattern here is simple: one public entry point, a deny-by-default authentication gate with a second factor, and an automated layer that turns repeated failures into firewall bans. Your applications stop being independent login targets and become services that only authenticated users can reach. Plan to revisit the setup every few months. Read the release notes for your pinned images, rerun the fail2ban-regex tests, and rescan your public address. A proxy that was secure when you built it can drift as apps are added, so treat the access rules as living configuration.