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.
kenneth@vps~/docscat topology.txt
01 / full stack overview
Request flow
Media and storage
Backing services
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-upgradesconfigured 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
wwwflattened 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.euCNAME 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
.envfiles are never committed; each is owned bywww-datawithchmod 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 tostorage/logs/laravel.logand 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