kenneth@vps: /etc

kenneth@vps~cat README.md

Self-hosted Linux VPS

A self-managed Hetzner VPS that runs my production workloads — CollectorWWII, client projects, and this site. Ubuntu hardening, Docker services, Nginx reverse proxying, Cloudflare DNS/WAF, Bash monitoring with Discord alerting, and automated offsite backups.

host
Hetzner VPS (CX22)
os
Ubuntu 22.04 LTS
domains
4 production domains, all behind Cloudflare
workloads
Laravel applications, MySQL, Redis, media services — live traffic

kenneth@vps~/docscat topology.txt

01 / full stack overview

Request flow

BrowserCloudflare DNS + WAFHetzner VPS (Ubuntu 22.04)UFWNginx (SSL termination)PHP-FPM 8.2 / app

Media and storage

Laravel Storage facadeBackblaze B2Cloudflare CDN cache

Backing services

MySQL 8 · Redis · Laravel queue worker · Laravel scheduler (cron)

kenneth@vps/etc/sshsudo cat sshd_config

02 / server hardening

SSH hardening

Root login and password auth are disabled — only SSH key pairs are accepted, on a non-default port.

# /etc/ssh/sshd_config
PermitRootLogin        no
PasswordAuthentication no
PubkeyAuthentication   yes
AllowUsers             kenneth
Port                   2222

User management

A dedicated non-root user owns the application files; sudo is scoped to specific service restart commands only.

  • Application files owned by www-data
  • Deployment user has no interactive shell
  • Sudoers entry scoped per service restart command only

kenneth@vps~sudo ufw status

UFW firewall

Only the ports the server actually needs are allowed; all other inbound traffic is denied by default.

# Allow only what is needed
ufw default deny incoming
ufw default allow outgoing
ufw allow 2222/tcp   # SSH
ufw allow 80/tcp    # HTTP (redirect only)
ufw allow 443/tcp   # HTTPS
ufw enable

kenneth@vps~sudo fail2ban-client status sshd

Fail2ban + automatic updates

Fail2ban blocks IPs after repeated failed SSH logins; unattended-upgrades applies security patches automatically.

  • Fail2ban SSH jail: 5 retries, 1 h ban
  • Nginx 403/404 flood protection via custom jail
  • unattended-upgrades configured for security packages

kenneth@vps/etc/nginx/sites-availablecat kennethclauwaert.com

03 / nginx reverse proxy

Virtual host structure

Each domain gets its own server block. SSL is terminated at Nginx via Let's Encrypt certificates managed by Certbot.

# /etc/nginx/sites-available/collectorwwii.eu
server {
    listen 80;
    server_name collectorwwii.eu www.collectorwwii.eu;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl http2;
    server_name collectorwwii.eu;

    ssl_certificate     /etc/letsencrypt/live/collectorwwii.eu/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/collectorwwii.eu/privkey.pem;

    root  /var/www/collectorwwii/public;
    index index.php;

    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }

    location ~ \.php$ {
        fastcgi_pass unix:/run/php/php8.2-fpm.sock;
        include      fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
    }
}

SSL + performance config

Shared SSL parameters live in one include so every virtual host stays consistent; gzip is enabled for text assets.

# /etc/nginx/conf.d/ssl_params.conf
ssl_protocols       TLSv1.2 TLSv1.3;
ssl_ciphers         HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
ssl_session_cache   shared:SSL:10m;
ssl_session_timeout 10m;
add_header Strict-Transport-Security
           "max-age=63072000; includeSubDomains";

# /etc/nginx/conf.d/gzip.conf
gzip              on;
gzip_types        text/plain text/css application/json
                   application/javascript text/xml;
gzip_min_length   1024;

Certbot auto-renewal

Certificates renew via a systemd timer; Nginx reloads automatically after renewal with --deploy-hook.

kenneth@vps~systemctl status php8.2-fpm laravel-worker

04 / php-fpm and laravel

PHP-FPM pool configuration

Each application runs in its own pool under its own system user, so one app's failures can't affect another.

# /etc/php/8.2/fpm/pool.d/collectorwwii.conf
[collectorwwii]
user  = www-data
group = www-data
listen = /run/php/php8.2-fpm.sock

pm                  = dynamic
pm.max_children     = 10
pm.start_servers    = 2
pm.min_spare_servers = 1
pm.max_spare_servers = 3

Queue worker + scheduler

The queue worker and scheduler run as systemd services, not bare cron — restart-on-failure and journald logging for free.

# /etc/systemd/system/collectorwwii-worker.service
[Unit]
Description=CollectorWWII Queue Worker
After=network.target mysql.service redis.service

[Service]
User=www-data
WorkingDirectory=/var/www/collectorwwii
ExecStart=/usr/bin/php artisan queue:work --sleep=3 --tries=3
Restart=on-failure
RestartSec=5s

[Install]
WantedBy=multi-user.target

kenneth@vps~dig kennethclauwaert.com +short

05 / cloudflare — dns, cdn, waf

DNS and proxying

All domains are DNS-managed through Cloudflare; orange-cloud proxy mode hides the origin IP and enables DDoS mitigation.

  • A records point to VPS IP; Cloudflare proxy hides origin
  • CNAME for www flattened to root at DNS level
  • MX records for email routed separately, not proxied

Caching strategy

Static assets cache at Cloudflare's edge via a Page Rule; purge is scripted and triggered on each deploy.

  • Cache Everything for /assets/*
  • Edge TTL: 1 month for versioned static files

WAF and security rules

The free WAF tier blocks common attack patterns; custom rules limit admin paths and block known bot user agents.

  • OWASP managed ruleset enabled at Cloudflare edge
  • Custom rule: block /admin/* for non-BE/NL geos
  • Rate limiting on contact form endpoint (10 req/min)

Backblaze B2 via Cloudflare CDN

Media is stored on B2 and served through a Cloudflare-cached custom domain — a CDN origin at zero egress cost.

  • Public B2 bucket; cdn.collectorwwii.eu CNAME to B2 endpoint
  • Laravel url() on MediaFile returns the CDN URL, not B2 direct

kenneth@vps~crontab -l

06 / monitoring and alerting

Crontab setup

The uptime check runs every 5 minutes; the Laravel scheduler runs every minute as a separate cron entry.

# crontab -e

# Uptime monitor every 5 minutes
*/5 * * * * /usr/local/bin/check-uptime.sh

# Laravel scheduler
* * * * *   cd /var/www/collectorwwii && php artisan schedule:run

Disk and memory checks

A daily script checks disk usage and free RAM, alerting to Discord if disk exceeds 80% or free RAM drops below 100 MB.

kenneth@vps~/bincat check-uptime.sh

Uptime monitor — Bash + Discord webhook

A cron-driven script checks each domain every five minutes and fires a Discord webhook on a non-2xx status or timeout.

#!/bin/bash
# /usr/local/bin/check-uptime.sh

WEBHOOK="https://discord.com/api/webhooks/..."
SITES=("https://collectorwwii.eu" "https://kennethclauwaert.com")

for site in "${SITES[@]}"; do
  STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
               --max-time 10 "$site")

  if [[ "$STATUS" != "200" ]]; then
    curl -s -X POST "$WEBHOOK" \
      -H 'Content-Type: application/json' \
      -d "{\"content\": \"DOWN: $site — HTTP $STATUS\"}"
  fi
done

kenneth@vps~/bincat backup-db.sh

07 / backup strategy

Automated MySQL dumps

A nightly script dumps all databases, compresses them, and uploads the archive to a private B2 bucket via the B2 CLI.

#!/bin/bash
# /usr/local/bin/backup-db.sh

DATE=$(date +%Y-%m-%d)
BACKUP_DIR="/var/backups/mysql"
BUCKET="my-backups-bucket"

mysqldump -u root -p$DB_PASS collectorwwii \
  | gzip > "$BACKUP_DIR/collectorwwii-$DATE.sql.gz"

b2 file upload "$BUCKET" \
   "$BACKUP_DIR/collectorwwii-$DATE.sql.gz" \
   "db/collectorwwii-$DATE.sql.gz"

# Prune local copies older than 7 days
find "$BACKUP_DIR" -mtime +7 -delete

What is backed up, and retention

Backups cover the database and application files; B2 lifecycle rules delete objects older than 30 days.

  • MySQL dumps — nightly, compressed, offsite to B2
  • Application files — weekly rsync snapshot to B2
  • Environment files — manually encrypted and stored offline
  • B2 lifecycle rule: delete objects > 30 days
  • Monthly restore test: re-import latest dump to staging

Hetzner snapshots

A weekly Hetzner VPS snapshot adds full-disk recovery for catastrophic failures or migrations.

kenneth@vps~ls runbooks/

08 / how it all fits together

  • zero-downtime-deploys A shell script pulls from Git, installs Composer dependencies, runs migrations in maintenance mode, then toggles off maintenance and reloads PHP-FPM.
  • redis-for-queues-and-cache Redis handles Laravel's queue backend and app-level caching, running as a Docker container bound to localhost only.
  • environment-separation .env files are never committed; each is owned by www-data with chmod 640, readable only by the app user and root.
  • multi-domain-from-one-vps Separate Nginx server blocks, webroots, PHP-FPM pools, and database users per domain — sharing the machine, isolated logically.
  • log-management Nginx and PHP-FPM logs rotate via logrotate; Laravel logs write to storage/logs/laravel.log and are checked in the deploy health check.
  • cloudflare-only-entry The VPS origin IP is never exposed — UFW allows HTTPS only from Cloudflare's published IP ranges.

kenneth@vps~cat NOTES.md

09 / what running this teaches you

Managing a production VPS solo forces a level of operational responsibility that's hard to simulate. When something breaks at 2am, you're the on-call engineer.

The monitoring scripts grew as I hit real incidents — a logging bug that filled the disk overnight, a backup restore test that revealed a missing table.

Stack summary

Ubuntu 22.04 · Hetzner VPS · Nginx · PHP-FPM 8.2 · MySQL 8 · Redis · Certbot · Cloudflare DNS + WAF + CDN · Backblaze B2 · Fail2ban · UFW · Bash · cron · systemd · Discord webhooks

exit 0