Securing self-hosted services beyond the server
Once the host is hardened, the app becomes the attack surface — least privilege, secret management, database exposure, backups and the specific settings that matter per service.
A hardened server running an insecure service is still insecure. Once the host itself is locked down — see the hardening checklist — the remaining risk is in the applications. This guide covers the practices that separate a properly secured self-hosted stack from one that merely looks tidy.
Never expose the app directly
The single highest-value rule: your application binds to 127.0.0.1 and only the reverse proxy faces the internet. Caddy or Nginx terminates TLS, handles certificates, and proxies to the app — the app's port never opens to the world.
Least privilege for everything
- ▹Run each service as its own unprivileged user, not root and not shared.
- ▹Use a separate database user per application with only the permissions it needs.
- ▹Give the app write access only to its own data directory, not to /etc or your home.
- ▹Do not run multiple apps as the same user — one compromise then reaches all of them.
Secrets management
API keys, database passwords and tokens do not belong in a git repo, in a shell history file, or in the app's source. Keep them in a file with 600 permissions owned by the service user, or in a systemd EnvironmentFile that is not world-readable. Our SSH guide covers the key hygiene that starts this discipline.
Note · Rotate any secret that has ever been in a repo, a screenshot, or a paste — assume it is compromised once it has been exposed.
Databases must never be internet-facing
A database on 0.0.0.0 is the classic self-hosting mistake. Bind Postgres or MySQL to 127.0.0.1, require a password, and reach it over the local socket or loopback. If a remote client needs access, tunnel it — do not open 5432.
Service-specific settings that matter
| Service | Setting that actually matters | Why |
|---|---|---|
| SSH | PasswordAuthentication no | Removes brute force entirely |
| Caddy/Nginx | App bound to 127.0.0.1 | Only the proxy is exposed |
| Postgres | listen_addresses = localhost | Database not reachable externally |
| Docker | Do not publish DB ports; use internal networks | Default publishing leaks the DB |
| WireGuard | Keys-only, UDP port from firewall | No password surface at all |
| No open relay; SPF/DKIM/DMARC | Otherwise you become a spam source |
Backups are a security control
Ransomware and accidental deletion both work by destroying what you would restore from. Encrypted, off-site, tested backups are what make a security incident survivable. Our Restic guide covers encrypted off-site backups, and a backup you have never restored from is not a backup.
Keep it patched
Unpatched app dependencies are the most common real-world entry point, ahead of the host itself. Enable automatic security updates, and make updating a dependency a routine part of deploying rather than an emergency.
FAQ
QIs exposing an app on 80 without a proxy fine?
AIt works, but you then handle TLS, headers and rate limiting yourself, and the app port is what the internet sees. A reverse proxy is the better default.
QHow do I keep secrets out of git?
AKeep them in a separate file with 600 permissions owned by the service user, referenced by a systemd EnvironmentFile, and never committed.
QHow often should I test backups?
AQuarterly at minimum — restore a file and a database from the backup. An untested backup is an assumption, not a plan.
QWhat is the most common self-hosting mistake?
APublishing a database port. Bind it to localhost and access it over the local socket.