dev notes

Linux, containers, and the quiet joy of things that just work.

Personal notes on self-hosting, infrastructure automation, and whatever I'm debugging this week.

recent posts
Running a private Wireguard exit node on a €4/mo VPS
A minimal setup: one kernel module, one config file, NAT via iptables. No panels, no dashboards. Just packets.
networkinglinuxwireguard
Docker Compose patterns I keep reusing
A growing list of compose snippets that solve real problems — health checks that actually work, shared networks, secrets without .env leaks.
dockerdevops
Caddy vs nginx: a pragmatic comparison for homelab use
I ran both for six months. Here's what actually matters when you're the only user and the only admin.
nginxcaddyhomelab
Automated backups with restic + rclone to Backblaze B2
Set it up once, forget about it. A cron job, a retention policy, and an offsite copy for under $0.50/month.
backuplinuxautomation
Monitoring three VPS instances with one Prometheus + Grafana stack
node_exporter on each host, a single scrape config, one dashboard. Alerts via a Telegram bot I wrote in an afternoon.
monitoringgrafanaprometheus

About

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.

Current stack

Ubuntu 24.04 LTS
Docker + Compose
Python / FastAPI
nginx
PostgreSQL
Redis
Prometheus
Grafana
Restic / B2
Wireguard
Ansible
Git / Forgejo

Servers currently running: one in the Netherlands, one in Sweden, one in Germany. All on cheap European hosters.

Contact: alex@example.dev

Docker Compose patterns I keep reusing

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.

Health checks that gate dependent containers

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.

Shared internal network

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.

Secrets without .env file leaks

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
On servers I own, I store secrets in /etc/myapp/secrets/ with 600 permissions, owned by root. Simple and auditable.

Automatic container restart + log limits

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.

Caddy vs nginx: a pragmatic comparison for homelab use

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.

Automatic TLS is Caddy's killer feature

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.

nginx wins on raw documentation

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.

Performance is a non-issue for personal use

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.

My current take

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.

Running a private Wireguard exit node on a €4/mo VPS

This is the setup I've been using for two years. No dashboard, no web UI. Just a config file and a firewall rule.

Server setup (Ubuntu 24.04)

# 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
Replace eth0 with your actual interface name — check with ip route | grep default.

Client config

[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.

Automated backups with restic + rclone to Backblaze B2

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.

Install and init repo

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

Backup script

#!/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

Cost

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.

Monitoring three VPS instances with one Prometheus + Grafana stack

My monitoring setup is intentionally simple. One Prometheus + Grafana instance (on the NL server), node_exporter on all three, one dashboard, alerts to Telegram.

node_exporter on each host

# 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

Prometheus scrape config

scrape_configs:
  - job_name: vps
    static_configs:
      - targets:
          - server-nl:9100
          - server-se:9100
          - server-de:9100
        labels:
          env: prod

Telegram alerts

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 }}'
I get alerts for: CPU > 85% for 5m, disk > 80%, memory > 90%, and any host going down. That covers 95% of real incidents.