Personal notes on self-hosting, infrastructure automation, and whatever I'm debugging this week.
I'm Alex — a backend developer based in Europe. I work on distributed systems by day and run a small fleet of VPS servers by night, mostly to avoid paying SaaS prices for things I can host myself.
This blog is a personal notebook. I write when something takes me more than an hour to figure out and I think I'll forget it again.
Servers currently running: one in the Netherlands, one in Sweden, one in Germany. All on cheap European hosters.
Contact: alex@example.dev
After running Compose in production for a few years, some patterns have become muscle memory. This is a living document — I update it when I discover something actually useful.
The default depends_on only waits for a container to start, not to be ready. Use condition: service_healthy to fix that:
services:
db:
image: postgres:16-alpine
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER"]
interval: 5s
timeout: 3s
retries: 5
app:
image: myapp:latest
depends_on:
db:
condition: service_healthy
Postgres will actually be accepting connections before your app tries to connect. Obvious in hindsight.
If you run multiple compose stacks and need them to talk to each other, declare an external network:
networks:
internal:
external: true
name: shared_net
Create it once with docker network create shared_net. Services in different stacks can now reach each other by container name.
Avoid putting secrets directly in .env files that live next to docker-compose.yml in a repo. Instead, pass them at deploy time:
export DB_PASSWORD=$(cat /run/secrets/db_password)
docker compose up -d
/etc/myapp/secrets/ with 600 permissions, owned by root. Simple and auditable.services:
app:
restart: unless-stopped
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
Without max-size, a chatty container will eventually fill your disk. Ask me how I know.
I ran Caddy on my Swedish VPS and nginx on the Netherlands one for about six months. Here's the honest comparison from someone who isn't trying to sell you either.
Caddy handles Let's Encrypt automatically. You write:
example.com {
reverse_proxy localhost:8080
}
And it just works. Certificate issued, renewed, done. For nginx you set up certbot, a systemd timer, a deploy hook, and then pray.
For a homelab with 5–10 domains this saves maybe 30 minutes of setup. Not dramatic, but pleasant.
When something goes wrong with nginx at 2am, there are 15 years of Stack Overflow answers. Caddy is excellent but its community is smaller and some edge cases are harder to debug.
Both can serve thousands of requests per second on a €4 VPS. If this is relevant to your workload, you have bigger problems than which reverse proxy to pick.
New project with standard HTTPS needs → Caddy. Anything involving complex rewrites, upstream configuration, or where I want to copy-paste a known-good config → nginx. I run both and don't feel bad about it.
This is the setup I've been using for two years. No dashboard, no web UI. Just a config file and a firewall rule.
# Install
apt install -y wireguard
# Generate keys
wg genkey | tee /etc/wireguard/server_private.key | wg pubkey > /etc/wireguard/server_public.key
# /etc/wireguard/wg0.conf
[Interface]
PrivateKey = <server_private_key>
Address = 10.8.0.1/24
ListenPort = 51820
PostUp = iptables -A FORWARD -i %i -j ACCEPT; iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE
PostDown = iptables -D FORWARD -i %i -j ACCEPT; iptables -t nat -D POSTROUTING -o eth0 -j MASQUERADE
[Peer]
PublicKey = <client_public_key>
AllowedIPs = 10.8.0.2/32
# Enable forwarding
echo 'net.ipv4.ip_forward=1' >> /etc/sysctl.conf
sysctl -p
# Start
systemctl enable --now wg-quick@wg0
eth0 with your actual interface name — check with ip route | grep default.[Interface]
PrivateKey = <client_private_key>
Address = 10.8.0.2/24
DNS = 1.1.1.1
[Peer]
PublicKey = <server_public_key>
Endpoint = <server_ip>:51820
AllowedIPs = 0.0.0.0/0
PersistentKeepalive = 25
That's the whole thing. Add peers by appending [Peer] blocks to the server config and running wg addconf wg0 <(wg-quick strip wg0) or just restarting the interface.
Restic is the best backup tool I've used. It deduplicates, encrypts client-side, and can push to almost any backend. Here's my setup across three VPS servers.
apt install -y restic
# Set up B2 credentials in environment
export B2_ACCOUNT_ID=your_key_id
export B2_ACCOUNT_KEY=your_app_key
export RESTIC_PASSWORD=your_strong_passphrase
restic -r b2:your-bucket-name:/server-nl init
#!/bin/bash
set -euo pipefail
export B2_ACCOUNT_ID="..."
export B2_ACCOUNT_KEY="..."
export RESTIC_PASSWORD="..."
REPO="b2:your-bucket:/server-nl"
restic -r "$REPO" backup \
/etc \
/opt/myapp \
/var/lib/docker/volumes \
--exclude="*.log" \
--exclude="*/cache/*"
restic -r "$REPO" forget \
--keep-daily 7 \
--keep-weekly 4 \
--keep-monthly 3 \
--prune
Store this at /opt/backup.sh, chmod 700, owned by root. Add to cron:
0 3 * * * root /opt/backup.sh >> /var/log/backup.log 2>&1
Three servers, ~2GB of config and data each, after deduplication: roughly $0.30/month on B2. Alerting when the backup job fails is left as an exercise — I use a dead man's switch via a simple HTTP ping to a self-hosted Uptime Kuma instance.
My monitoring setup is intentionally simple. One Prometheus + Grafana instance (on the NL server), node_exporter on all three, one dashboard, alerts to Telegram.
# On each VPS to be monitored
docker run -d \
--name node_exporter \
--restart unless-stopped \
--net host \
--pid host \
-v /:/host:ro,rslave \
quay.io/prometheus/node-exporter:latest \
--path.rootfs=/host
Expose port 9100 only to your monitoring server's IP via ufw:
ufw allow from <monitoring_server_ip> to any port 9100
scrape_configs:
- job_name: vps
static_configs:
- targets:
- server-nl:9100
- server-se:9100
- server-de:9100
labels:
env: prod
Alertmanager supports Telegram natively since v0.26. Create a bot via @BotFather, get the chat ID, and add to alertmanager.yml:
receivers:
- name: telegram
telegram_configs:
- bot_token: 'your_token'
chat_id: 123456789
message: '{{ .CommonAnnotations.summary }}'