mrwick/notebook

drag or arrow keys to move · scroll to zoom · esc to exit

guides

Build the one-VPS stack from zero

Updated

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:

  1. Start its container on the web network.
  2. Add its hostname to the one_factor rule in the Authelia config, and restart Authelia.
  3. 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.