Securing the Homelab: Bastion, Wireguard, and Authelia SSO

Cybersecurity shield with circuit traces

The Starting Point

My 'Dullbox' homelab has been running well for a while, containerised services, nice wee monitoring stack, everything accessible over HTTPS thanks to NPM (Nginx Proxy Manager) with Let's Encrypt certs, all behind Cloudflare. I've had Tailscale set up for remote access for a while with no issues, but since I use WireGuard for all my outbound VPNs anyway, I decided to set it up for incoming traffic too, and understand it better from the other side.

Wireguard is well respected, has great docs and community support, and so far in my experience, it is very secure. However, "it's behind Wireguard, so it's fine." isn't really fine when I've never used it for incoming traffic to my home network. I'm still trying to expand my knowledge of potential VPN attack vectors, but to be honest so far, mostly I'm thinking of human error or social engineering as being the likeliest pitfalls. If one of my Wireguard keys leaked through a simple mistake, or a machine/device with the config was somehow compromised, my whole network would be exposed. Yikes.

So, this project was about closing that gap properly, going for defence in depth: use multiple independent layers, so that losing any single one doesn't hand someone the keys to everything. This is half hobbyist, half education for me, I'm not that interesting a target or protecting anything particularly critical, and it's not like I have a passwords.txt sitting on a machine, but I feel the exercise of thinking it through, tinkering, and implementing it properly is worthwhile in itself.

The Plan

I broke it down into phases, like most projects, each one tested and 'leaving the lab' in a working state before moving on to the next:

  1. A new 'Bastion' LXC on my Proxmox machine — my hardened SSH jump host with key and TOTP auth
  2. Wireguard config/restriction — limit what VPN clients can actually reach
  3. Authelia — self-hosted SSO in front of all services
  4. NPM integration — wire Authelia into every proxy host

I had looked into Authentik as well, but I don't need the full OIDC/identity setup it offers, so Authelia seemed to be the clear choice for my project. Like Authentik, Authelia is also open-source, well respected, and has solid docs. So, a compromised or leaked key combined with a stolen or cracked password won't let you get any further than Bastion, which is where the TOTP device comes in. I have Bitwarden as a secondary password manager, and it has a pretty nifty 2fa/code flow.

Phase 1: Bastion LXC

The bastion is a very minimal Debian 12 LXC (512MB RAM, 8GB disk) with one purpose only: being the SSH entry point, nothing else. I set up a single non-root user with passwordless sudo, copied over my SSH public key, and locked down /etc/ssh/sshd_config to refuse root login, refuse password auth, and require both a public key and a keyboard-interactive challenge. I feel it's pretty solid, but looking forward to try and break or escape it.

The keyboard challenge is TOTP using libpam-google-authenticator (open source, yes please). I found the PAM config needed a small adjustment: @include common-auth had to be commented out because it was falling through to prompt for a Unix password, which doesn't exist since the user has no password set. I was scratching my head for a second, and as per, it was staring me in the face. Anyway, with that sorted, the login flow is: key accepted, six-digit code from Bitwarden's TOTP authenticator, then a shell. It's simple and clean, and so far I've not found any potential leaky pipes in the setup.

UFW is enabled with just a single rule, port 22 open. That's it for the bastion, easy to maintain, hopefully hard to breakthrough.

Phase 2: Wireguard Restriction

The Wireguard gateway is a Pi Zero 2W on my LAN. I love the Zero board, and use them in a lot of my DIY gadgets and network setups. When I had first set it up a few weeks ago, connecting to it via Wireguard gave full access to the 192.168.1.0/24 subnet which at first I thought was great...but it's definitely not great from a least-privilege perspective. The change here was to replace the FORWARD ACCEPT rule with one that only allows forwarding to the bastion IP (192.168.1.208), with a DROP for everything else.

I was rolling, I'd planned it out, but...it wasn't acting how I'd expected. After applying the new PostUp/PostDown rules and confirming that SSH to the bastion still worked, I tried pinging another host on the LAN from my phone on mobile data. It went straight through when it should have been blocked. Hmmmm.

Again, scratching my head, and then...oh yea, obvious. The Pi was also running UFW.. UFW manages the FORWARD chain through its own sub-chains, and the ufw-before-forward chain had a rule accepting all forwarded ICMP echo-requests/pings. So this rule ran before my DROP rule ever got a chance. The fix was very simple, just adding ! -i wg0 to that rule, so UFW's ICMP acceptance only applies to non-Wireguard traffic and lets the Wireguard-specific rules take over:

# /etc/ufw/before.rules
# before:
-A ufw-before-forward -p icmp --icmp-type echo-request -j ACCEPT

# after:
-A ufw-before-forward -p icmp --icmp-type echo-request ! -i wg0 -j ACCEPT

After that, pings to anything other than the bastion were blocked. I also updated the phone's Wireguard client config to narrow AllowedIPs down to just the bastion IP, so the phone's routing table reflects the actual restriction rather than advertising the full subnet. Not totally necessary, but cleaner.

Phase 3: Authelia

Authelia isn't something I'd used before, as mentioned though it's an open source auth server that slots in with reverse proxies using the auth_request parameter. The idea is that every request to one of my protected domains gets verified against Authelia before it's proxied through. If you're not authenticated, you get redirected to the Authelia portal to log in, and then once you're in, a session cookie covers all my protected subdomains until it expires.

I set it up in a new LXC (512MB, Debian 12) alongside Redis for session storage and SQLite as my db. Redis is something I've been working with a lot more over the last year, and it uses systemd namespace sandboxing that fails in an unprivileged LXC container. To work with this I enabled nesting=1 on the LXC in Proxmox, which allows the sandbox to work.

At this point I was reading some posts online about Authelia, most of which turned out to be a little out of date. I found that config format had changed in the newer version I was running (v4.39), the jwt_secret is stored under identity_validation.reset_password not top level, server.trusted_proxies doesn't exist at all, and the session config uses a cookies list rather than the previous key set.

I set up a single user with an argon2id-hashed pw, configured my TOTP as the 'second factor', and pointed the notifier at a local file instead of an external email server, so the enrollment codes land in /var/lib/authelia/notification.txt.

Phase 4: NPM Integration

With Authelia running, the next step was wiring it into NPM. Each proxy host in NPM has an advanced config field that accepts raw nginx blocks. The auth block sets up an internal location that proxies the request to Authelia's /api/authz/auth-request endpoint, with auth_request pushing every request through it:

location /authelia {
    internal;
    set $upstream_authelia http://192.168.1.209:9091/api/authz/auth-request;
    proxy_pass $upstream_authelia;
    proxy_pass_request_body off;
    proxy_set_header Content-Length "";
    proxy_set_header X-Original-URL $scheme://$http_host$request_uri;
    proxy_set_header X-Original-Method $request_method;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header X-Forwarded-Host $http_host;
    proxy_set_header X-Forwarded-Uri $request_uri;
    proxy_set_header X-Forwarded-For $remote_addr;
}

auth_request /authelia;
error_page 401 =302 https://auth.onlydarkmode.com/?rd=$scheme://$host$request_uri;

Again here, there was a slight change in my version, it turns out that in v4.38 and later the X-Original-Method header is required. I missed this at first and got a 400 with "header 'X-Original-Method' is empty".

NPM stores its state in a SQLite db inside a Docker container, and the nginx conf files are generated from that. I started chucking my nginx blocks into each proxy's settings on the NPM UI then realised I could just update the conf files and the database directly via docker exec saving a lot of clicking about and copy/pasting...I've got quite a few proxy hosts.

Proxmox, Gitea, Grafana, Uptime Kuma, the new wiki I'm building, and a bunch more are now protected. NPM's admin UI and the Authelia portal itself I left alone...you can't authenticate through Authelia to reach Authelia.

Where Things Are Now

For an afternoon's tinkering, I'm pretty happy and feel I've upped the security/hardening pretty well. The layers look like this:

  • Remote SSH: Wireguard to bastion only, then key and TOTP to get a shell
  • Web services: HTTPS via NPM, every request verified by Authelia (password and TOTP)
  • Internal access: direct on the LAN, no auth layer...this is a home lab, not a zero-trust office

It's not perfect, what I really want is VLAN isolation, I can't do that on my ISP branded router, so it will have to wait until I buy and setup a decent one, either running OPNsense or pfSense. A handful of services are still accessible on the LAN without needing any authentication, but that's simply because I didn't feel it was needed, and I'm pretty confident I've covered the main exposure/attack vectors. The Wireguard and bastion combo feels like a solid improvement over my previous "Wireguard key = LAN access" setup, though really I'm just trying to cover all bases and learn some more.

Potential changes/improvements I'm thinking of right now are WebAuthn/passkey support in Authelia once TOTP is in, and maybe a Teleport deployment for proper cert-based SSH and audit logging. I just can't get enough of those logs (*sings in Kylie's voice perfectly*)

Last updated: