Guide
A security baseline for a home server
Almost nobody is compromised by a weak TLS cipher. They are compromised by an exposed login page, a leaked .env file, or a Docker socket handed to a container.
How do you secure a self-hosted home server?
In priority order: use SSH keys and turn off password authentication, publish no application ports so that one reverse proxy is the only listener, never expose an application's own login form to the internet, enable automatic OS security updates, keep secrets out of git, treat the Docker socket as root and put a socket proxy in front of it, run each service as its own user, and keep offline or append-only backups as your ransomware control. That list is finite and it covers how people actually get compromised.
This list is finite and it is in order. The ordering is the useful part: it is roughly the order in which home servers actually get compromised, which is not the order most hardening guides use. Do the first four and you have removed the great majority of realistic risk. Everything below item nine is optional, and the last section is a list of popular advice you can skip.
1. SSH keys, and turn password authentication off#
OpenSSH still ships with PasswordAuthentication yes as the documented default, and PermitRootLogin prohibit-password. A server with a routable address and password login enabled is being brute forced right now, continuously, by everybody.
# /etc/ssh/sshd_config.d/10-hardening.conf
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no # otherwise passwords come back through PAM
PubkeyAuthentication yes
AuthenticationMethods publickey
AllowUsers youssh-copy-id you@server # do this FIRST, and test it
sudo sshd -t # validate before reloading
sudo systemctl reload sshTwo operational notes. KbdInteractiveAuthentication defaults to yes, and leaving it on is how people disable passwords and still get a password prompt through PAM. And keep a second SSH session open while you reload, so a mistake is recoverable without a keyboard and a monitor.
If the server is only reachable over a private overlay network, this matters slightly less and you should still do it, because the overlay is one misconfiguration away from not being there.
2. Publish no ports, and make the proxy the only listener#
The default that removes an entire class of exposure: no container publishes a port to the host. Services join a shared Docker network, and Caddy reaches them by container name. The only listeners on the machine are 22, 80 and 443.
This is not merely tidy. Docker's own documentation states that traffic to a published container port is diverted in the nat table before it reaches the INPUT chain that ufw uses, so a container published with -p 8080:80 is reachable even when your host firewall says that port is denied. People firewall the host, believe they are finished, and are not. Where you must publish, bind to an interface: 127.0.0.1:8080:80.
Watch for the services that quietly break this rule. Netdata's dashboard on port 19999 has no authentication at all by design, exposing process lists, running containers and network connections to anyone who can reach it. Caddy's own admin API on 2019 replaces the entire running configuration for anyone who can POST to it, which is fine while it stays on localhost and is not fine under network_mode: host. Portainer prints a setup token in its logs and the admin account must be claimed within five minutes of first start, so an instance left unclaimed on a reachable port is an open invitation.
Reverse proxy and TLS covers the proxy build, and Remote access without port forwarding covers getting in without opening anything at all, which is the better answer for most home servers.
3. Do not expose an application's own login page#
This is the highest-value single decision on the page, and it is the one most people get wrong because it is inconvenient.
Application login forms are written by application developers. Most have no rate limiting; Homepage has none on its password login, and Glance shipped a fix in v0.8.6 for an X-Forwarded-For spoof that bypassed its auth rate limit. Any pre-authentication bug in any one of your twenty services is a foothold, and you are trusting twenty different projects to have got it right.
Two workable shapes:
- Private by default. The service is only reachable over your overlay network. Nothing on the public internet can send it a request. This is the right answer for the large majority of self-hosted services.
- Forward authentication in front. Authelia at 35 MB gates the HTTP request before it reaches the app, so unauthenticated traffic never touches your application code. Be aware of the shape of it: forward auth is all-or-nothing per path, so anything without a browser needs a bypass rule, and bypass rules are where the security leaks. Pocket ID is the opposite trade, a passkey-only OIDC provider that signs users into apps that already speak OIDC and does nothing for apps that do not.
Single sign-on for self-hosters is honest about how far this actually gets you, which is less far than the marketing suggests: across the tools profiled here, 31.1% of the ones with user accounts have no usable identity integration at all.
4. Automatic OS security updates#
Turn them on. On Debian the default unattended-upgrades configuration installs security updates and not feature updates, which is the right risk profile for a machine you touch monthly.
sudo apt install unattended-upgrades apt-listchanges
sudo dpkg-reconfigure -plow unattended-upgrades
# Put local changes in /etc/apt/apt.conf.d/52unattended-upgrades-local
# so a package upgrade does not overwrite them.This is a different question from container updates, which you should not automate. Distribution security patches are backported specifically to avoid changing behavior; an upstream container release is a new version of the application with migrations attached. An update strategy that does not lose data makes that case in full.
5. Secrets out of git, permanently#
Your stacks directory should be in git. Your .env files should not be. Add .env and *.key to .gitignore before the first commit, because git history is forever and a secret pushed once is a secret rotated, not deleted.
# /srv/stacks/.gitignore
.env
*.key
*.pem
acme.jsonThen check the ones that leak by design. Homepage stores widget API keys in cleartext YAML. Vaultwarden's ADMIN_TOKEN needs every $ doubled to $$ in a compose file or the value is silently mangled, and a plaintext token there triggers a warning for good reason. Anything with an encryption key in its config, Authelia's storage.encryption_key being the sharp example, is unrecoverable if you lose the key: restore a database dump onto an instance with a different key and the encrypted columns are gone.
6. The Docker socket is root, and socket-proxy is the mitigation#
Say it plainly: anything that can reach /var/run/docker.sock can create a privileged container that mounts the host filesystem, which is root. Mounting the socket read-only does not make the API read-only. Membership in the docker group is equivalent to passwordless sudo.
That matters because a lot of useful software wants the socket: dashboards, log viewers, monitoring agents, update notifiers, Traefik for label discovery. The mitigation is a filtering proxy that exposes only the API sections a given consumer needs.
# /srv/stacks/socket-proxy/compose.yaml
name: socket-proxy
services:
socket-proxy:
image: ghcr.io/tecnativa/docker-socket-proxy:0.5.0
restart: unless-stopped
environment:
CONTAINERS: 1 # enough for Traefik, Dozzle, Diun and most dashboards
POST: 0 # read-only: no create, no exec, no start
IMAGES: 0
NETWORKS: 0
VOLUMES: 0
EXEC: 0
SECRETS: 0
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
networks: [socket-proxy]
# publish nothing; consumers join the socket-proxy network
networks:
socket-proxy:
internal: trueConsumers then point at tcp://socket-proxy:2375 instead of the socket. Defaults in that image are deny-first: EVENTS, PING and VERSION are on, POST, AUTH and SECRETS are off, and everything else is opt-in. The residual risk is real but much smaller: a compromised Dozzle with CONTAINERS: 1 and POST: 0 can enumerate your containers, which is information disclosure, not host takeover.
The deeper fix is not running a root daemon at all. Podman is rootless by default and turns containers into systemd units, at the cost of some compose compatibility and no ports below 1024 without a sysctl change.
7. One user per service, and own the files#
Create a system account per service, or at minimum a shared unprivileged services group, and make the bind-mount tree owned by it. It costs five minutes per service and it converts "an application bug wrote somewhere it should not" from a host problem into a directory problem.
sudo useradd --system --no-create-home --shell /usr/sbin/nologin svc-paperless
sudo install -d -o svc-paperless -g svc-paperless -m 750 /srv/data/paperlessThen set the container to match: PUID/PGID on LinuxServer.io images, USERMAP_UID/USERMAP_GID on Paperless-ngx, or a plain user: "1001:1001" where the image supports it. Check before you assume, because the conventions are not consistent and plenty of images simply run as root.
8. Backups are a security control, not just a reliability one#
Ransomware on a home server is not common, and when it happens the deciding factor is whether your backups were reachable from the compromised host. A backup target the server can write and delete is a backup an attacker deletes.
Two shapes that work:
- Object storage with a restricted key. Give restic an application key scoped to one bucket, with versioning or object lock enabled at the provider, so deletes are recoverable for a retention window the host cannot shorten.
- Append-only over SSH. BorgBackup supports a forced command in
authorized_keys:command="borg serve --append-only --restrict-to-path /srv/backups/host1",restrict ssh-ed25519 AAAA.... The client can add data and cannot remove it. Note the documented caveat: append-only operates at the segment level, and pruning is still expressible, so treat it as strong protection rather than an absolute.
And test the restore. An untested backup is a belief, not a control. Backups that actually restore is the short version of why.
9. Where fail2ban and CrowdSec actually help#
They help on a service you have deliberately exposed to the internet with a login form and no rate limiting, on a mail server, and on any public web application where credential stuffing is a real traffic pattern. CrowdSec's addition over fail2ban is the community blocklist: you get IPs other participants have already seen attacking, which does measurably reduce opportunistic scanning noise before it reaches your app.
They are theatre on an SSH server that accepts only public keys. Nobody is brute forcing an ed25519 key, so what you have built is a log filter. They are also close to theatre in front of services that are already private, where the only traffic is yours.
The decision rule: if the thing being protected is not reachable from the internet, skip it. If it is reachable and has a login form, use CrowdSec vs Fail2ban to pick one, and understand you are buying noise reduction, not a boundary.
Popular advice that is not worth your time#
- Moving SSH to port 2222. Log noise reduction, not a control.
- Disabling ICMP. Makes your network harder to debug and stops nobody.
- Port knocking. A VPN or overlay network does the same job with a real cryptographic identity.
- Hand-tuned TLS cipher suites. Caddy's defaults are current and maintained; your handcrafted list will be stale in eighteen months.
- Antivirus on a Linux server. Useful only if you serve files to Windows clients, and then it is protecting them, not you.
- Read-only container filesystems on a container that mounts the Docker socket. You have padlocked the window and left the door open.
- Two-factor on services that are already behind your overlay network. Put that effort into your registrar and your identity provider instead.
What to do next#
Work down items one to four this evening; they are under an hour together and they are most of the benefit. Then audit which of your containers currently mount the Docker socket, because that list is usually longer than people expect, and put a socket proxy in front of the ones that stay. After that, read An update strategy that does not lose data: an unpatched service you forgot about is the most common way a hardened server stops being hardened.
Questions#
Is fail2ban worth installing?
On an SSH server that only accepts keys, no. Brute forcing a public key is not a thing that happens, so fail2ban there is log hygiene rather than security. Where it earns its place is a login form you have deliberately exposed to the internet, particularly one with no built-in rate limiting, and on a mail server. CrowdSec adds a crowdsourced blocklist on top of the same idea, which does help against opportunistic scanning, but neither tool substitutes for not exposing the login in the first place.
Should I change the SSH port from 22?
It cuts log noise and nothing else. Every scanner that matters does a full port sweep, and moving to 2222 puts you on an unprivileged port any local user could bind if sshd were ever stopped. If your logs bother you, change it. Do not record it as a security control, and do not let it substitute for disabling password authentication, which is the change that actually matters.
Why is the Docker socket dangerous?
Because access to it is root on the host, with no qualification. Anything that can talk to /var/run/docker.sock can start a container that mounts / and runs as root, which is a complete host takeover. Mounting it :ro does not help: read-only applies to the socket file, not to the API. The same is true of membership in the docker group, which is why 'I run everything as a non-root user' is usually false on a Docker host.
Does a reverse proxy make my apps secure?
It gives you one TLS endpoint, one place for access control and one thing to patch, which is genuinely valuable. What it does not do is protect an application from bugs in its own request handling once traffic reaches it. A proxy plus forward authentication does protect the app, because unauthenticated requests never arrive. A proxy alone in front of an exposed login page moves the attack surface, it does not shrink it.
Do I need a firewall if Docker publishes ports?
You need to understand that Docker's published ports bypass ufw. Docker's own documentation states that traffic to a published container port is diverted in the nat table before it reaches the INPUT chain that ufw uses, so a port you believe is denied is reachable. The reliable fixes are to publish nothing (let the reverse proxy reach containers over a shared Docker network), or to bind explicitly to an interface with 127.0.0.1:8080:80.
How do backups protect against ransomware?
By existing somewhere the compromised host cannot delete them. A backup target the server can write and erase is a backup an attacker erases first. Use an object storage bucket with an application key restricted to that bucket plus object lock or versioning, or borg serve --append-only behind a forced SSH command, so the client can add data and cannot remove it. Then verify restores, because an untested backup is not a control.
Should I put two-factor authentication on everything?
On anything reachable from the internet, yes. On a service only reachable over your WireGuard overlay, it is usually effort better spent elsewhere: the device already authenticated with a key to get on the network. Spend the 2FA budget on the accounts that matter most, which are your SSH keys, your identity provider and your domain registrar, in that order.
Sources#
- OpenSSH sshd_config manual page
- Docker docs, packet filtering and firewalls
- Docker docs, rootless mode
- Tecnativa docker-socket-proxy, default permissions
- BorgBackup docs, borg serve and append-only mode
- Debian wiki, unattended-upgrades configuration
- CrowdSec documentation, introduction
- Netdata repository README, agent dashboard and licensing
- Portainer FAQ, setup token
Published . Last reviewed . Found something out of date? Tell us and we will fix it and log the change.