Build the one-VPS stack from zero
This guide builds the base that my other services sit on: one VPS, one
reverse proxy, and one login. It assumes a fresh Ubuntu server, a domain, and
basic comfort with SSH and Docker. Every hostname below is example.com.
Point the domain at the server
Create a wildcard A record, *.example.com, that points to the server’s public
IP. A wildcard means a new service needs no new DNS record.
Lock down SSH before anything else
Copy your public key to the server. Then turn off passwords in
/etc/ssh/sshd_config.d/00-hardening.conf:
PasswordAuthentication no
KbdInteractiveAuthentication no
PermitRootLogin prohibit-password
Open a second terminal and confirm that key login still works before you close the first one.
Set the firewall
Set the INPUT policy to drop, with a short list of exceptions. Add the SSH
rule first. If you set the policy before the rule, you lock yourself out.
iptables -A INPUT -i lo -j ACCEPT
iptables -A INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
iptables -A INPUT -p tcp --dport 22 -j ACCEPT
iptables -A INPUT -p tcp -m multiport --dports 80,443 -j ACCEPT
iptables -A INPUT -p udp --dport 443 -j ACCEPT
iptables -A INPUT -p icmp -j ACCEPT
iptables -P INPUT DROP
Port 443 over UDP carries HTTP/3. Repeat the rules with ip6tables for IPv6,
then save them with iptables-persistent.
Docker publishes container ports through its own rules, which skip INPUT.
So a container that publishes a port is open to the internet. Publish ports
only on the proxy, and bind any other published port to a private address.
Create the shared network
docker network create web
Every service joins this network. Caddy then reaches each one by its container name.
Run Caddy
services:
caddy:
image: caddy:2
container_name: caddy
restart: unless-stopped
ports:
- '80:80'
- '443:443'
- '443:443/udp'
volumes:
- ./caddy:/etc/caddy
- caddy_data:/data
networks: [web]
volumes:
caddy_data:
networks:
web:
external: true
Mount the directory ./caddy, not the single file ./Caddyfile. A mounted
file goes stale when an editor replaces it. The note
Caddy reload is not enough explains
why.
Put the first site in ./caddy/Caddyfile:
auth.example.com {
reverse_proxy authelia:9091
}
Add the login
Run Authelia on the same web network, with the container name authelia.
Then add the shared snippet to the Caddyfile:
(sso) {
forward_auth authelia:9091 {
uri /api/authz/forward-auth
copy_headers Remote-User Remote-Groups Remote-Email Remote-Name
}
}
Write the Authelia rules with default_policy: 'deny', as in
One VPS, one login. Before you restart Authelia,
check the config with a throwaway container:
docker run --rm -v ./config:/config authelia/authelia:4 \
authelia validate-config --config /config/configuration.yml
Authelia keeps sessions in memory, so each restart logs you out.
Add a service
For each new private app, do three things:
- Start its container on the
webnetwork. - Add its hostname to the
one_factorrule in the Authelia config, and restart Authelia. - Add a site block to the Caddyfile and reload Caddy:
rss.example.com {
import sso
reverse_proxy freshrss:80
}
Check the result
Open https://rss.example.com in a private window. You should land on the
login page. Log in, and the app should load. Then request a hostname that you
did not list in the Authelia rules. It should return a 403.
Back up before you rely on it
Copy /srv to another machine on a schedule, and open one backup to see that
it holds what you expect. See
How a password manager syncs for
why the check matters.