How to Rebuild and Secure a Small Hetzner Server with Cloudflare
How to Rebuild and Secure a Small Hetzner Server with Cloudflare
When a small production server gets compromised, the tempting fix is to delete the bad process, restart a few services, and move on.
That is rarely enough.
If the attacker had root access, the right move is usually to rebuild from a clean image, migrate only trusted data, rotate secrets, and reduce the chance that the same path works again.
This article is a practical walkthrough of that process using Hetzner Cloud and Cloudflare.
It covers what I actually care about on a small production host:
- clean server rebuild
- database backup and restore
- SSH key-only access
- deploy key rotation
- Docker port exposure
- Cloudflare DNS proxy
- Full strict TLS with an origin certificate
- Hetzner firewall rules
- direct IP blocking
- Cloudflare WAF basics
- final checks before leaving the server online
This is not enterprise security architecture. It is the baseline I want before I trust a small VPS again.
The Target Setup
For a typical Docker-based Laravel, PHP, Magento, WordPress, or Node app, the final shape should be:
Browser
-> Cloudflare DNS / Proxy / WAF
-> Hetzner Firewall
-> Nginx on the VPS
-> App container or PHP-FPM
-> Database on the private Docker network
Public services:
22/tcp, only from your own IP80/tcp, preferably only from Cloudflare443/tcp, preferably only from Cloudflare
Private services:
- PostgreSQL or MySQL
- Redis
- PHP-FPM
- Mailpit or other dev mail tools
- Docker socket
- queue workers
- admin-only tooling
The server should not expose a database, Redis, Mailpit, PHP-FPM, or Docker API to the public internet.
1. Do Not Reuse the Compromised Host
If the old server was compromised as root, do not rebuild production on top of it.
Use it only to extract data that you can justify:
- a database dump
- verified user uploads, if the application needs them
- manually reviewed environment values
Avoid copying:
/root/tmp/var/tmp/dev/shm- cron files
- systemd overrides
- old private keys
- the whole old
/var/www - old Docker volumes without review
For PostgreSQL in a Docker container, a clean custom-format dump looks like this:
cd /var/www/running-booker
DB_PASSWORD="$(grep '^DB_PASSWORD=' backend/.env | cut -d= -f2-)" \
docker exec -e PGPASSWORD="$DB_PASSWORD" running-booker-db-prod \
pg_dump -U rb_user -d running_booker -Fc \
> /tmp/running-booker-$(date +%F-%H%M).dump
The -Fc flag creates a PostgreSQL custom-format dump. It is not a text SQL file, and that is fine. Restore it later with pg_restore.
2. Pick a Hetzner Server
For a small app, start simple.
Hetzner's Cloud plans change over time, so check the official page before ordering. In practice:
CX22is a reasonable starting point for a small app, Nginx, and a modest database.CX32is a better default when the database, queues, imports, and build steps run on the same server.- Bigger plans make sense when imports, image processing, search, or traffic patterns justify them.
Do not run several unrelated production sites on the same tiny box unless you are comfortable with one weak app affecting the others.
When creating the server:
- choose an Ubuntu LTS image
- add your SSH public key
- attach a Hetzner Cloud Firewall
- enable backups or take snapshots before major changes
3. Create a Non-Root User
SSH into the new server as root using the key added during server creation:
ssh root@YOUR_SERVER_IP -i ~/.ssh/your_key
Create a deploy user:
adduser --disabled-password --gecos "" runningbooker
usermod -aG sudo runningbooker
Copy SSH access:
mkdir -p /home/runningbooker/.ssh
cp /root/.ssh/authorized_keys /home/runningbooker/.ssh/authorized_keys
chown -R runningbooker:runningbooker /home/runningbooker/.ssh
chmod 700 /home/runningbooker/.ssh
chmod 600 /home/runningbooker/.ssh/authorized_keys
Test from a second terminal:
ssh runningbooker@YOUR_SERVER_IP -i ~/.ssh/your_key
Do not disable root SSH until the non-root login works.
4. Lock Down SSH
Target state:
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
Apply:
sudo mkdir -p /etc/ssh/sshd_config.d
printf 'PermitRootLogin no\nPasswordAuthentication no\nKbdInteractiveAuthentication no\nPubkeyAuthentication yes\n' \
| sudo tee /etc/ssh/sshd_config.d/99-hardening.conf >/dev/null
sudo sshd -t
sudo systemctl reload ssh
Verify:
sudo sshd -T | egrep 'permitrootlogin|passwordauthentication|pubkeyauthentication'
Expected:
permitrootlogin no
pubkeyauthentication yes
passwordauthentication no
In Hetzner Firewall, restrict 22/tcp to your own IP address.
Get your IP:
curl https://ifconfig.me
5. Install Docker
Install the basic runtime:
sudo apt update
sudo apt install -y docker.io docker-compose-v2 git curl ufw fail2ban
sudo systemctl enable --now docker
sudo usermod -aG docker runningbooker
Log out and back in so the runningbooker user gets the docker group.
Verify:
docker --version
docker compose version
docker ps
groups
6. Rotate the Deploy Key
If a private Git deploy key existed on the old server, treat it as compromised.
Delete it from GitHub or GitLab, then create a new key on the new server:
mkdir -p ~/.ssh
chmod 700 ~/.ssh
ssh-keygen -t ed25519 -f ~/.ssh/running-booker-deploy -C "running-booker deploy"
cat ~/.ssh/running-booker-deploy.pub
Add the public key to the repository as a read-only deploy key.
Configure SSH:
cat > ~/.ssh/config <<'EOF'
Host github.com
HostName github.com
User git
IdentityFile ~/.ssh/running-booker-deploy
IdentitiesOnly yes
EOF
chmod 600 ~/.ssh/config ~/.ssh/running-booker-deploy
Test:
ssh -T git@github.com
Clone from the clean repository:
sudo mkdir -p /var/www
sudo chown runningbooker:runningbooker /var/www
cd /var/www
git clone git@github.com:your-org/your-repo.git running-booker
cd running-booker
Do not copy the old application directory from the compromised host.
7. Deploy and Restore the Database
Run the application's normal deploy process. For a Docker Compose app, that usually means building the app, starting the database, running migrations, and starting Nginx.
After the database container is healthy, copy the dump to the server and restore it:
docker cp /home/runningbooker/running-booker.dump running-booker-db-prod:/tmp/restore.dump
docker exec -i running-booker-db-prod \
pg_restore -U rb_user -d running_booker --clean --if-exists /tmp/restore.dump
Then restart the app:
docker compose -f docker-compose.prod.yml restart app nginx
If permissions are wrong after deploy, fix the specific mounted paths instead of weakening the whole tree. For example, if Nginx cannot read the frontend build:
sudo chmod o+rx /var/www /var/www/running-booker /var/www/running-booker/frontend /var/www/running-booker/frontend/dist
sudo find frontend/dist -type d -exec chmod 755 {} \;
sudo find frontend/dist -type f -exec chmod 644 {} \;
8. Put Cloudflare in Front
If the domain is registered at Namecheap or another registrar, change the nameservers there to the two nameservers Cloudflare provides.
In Cloudflare DNS:
A @ YOUR_HETZNER_IP Proxied
A www YOUR_HETZNER_IP Proxied
The records must be Proxied, shown as the orange cloud.
Useful docs:
Verify:
dig NS example.com +short
curl -I https://example.com
Expected Cloudflare headers:
server: cloudflare
cf-ray: ...
If the headers are not there, you are probably still hitting the origin directly or the domain is not using Cloudflare nameservers yet.
9. Use Full Strict TLS
In Cloudflare:
SSL/TLS mode: Full (strict)
Always Use HTTPS: On
Automatic HTTPS Rewrites: On
Useful docs:
For a Cloudflare-proxied origin, create an Origin Certificate:
Cloudflare Dashboard -> SSL/TLS -> Origin Server -> Create Certificate
Use hostnames:
example.com
*.example.com
On the server:
cd /var/www/running-booker
mkdir -p ssl
chmod 700 ssl
Save:
ssl/fullchain.pem
ssl/privkey.pem
Then:
chmod 600 ssl/fullchain.pem ssl/privkey.pem
docker compose -f docker-compose.prod.yml up -d --force-recreate nginx
Test locally:
curl -I http://127.0.0.1/healthz
curl -k -I https://127.0.0.1/healthz
The Cloudflare Origin Certificate is for Cloudflare-to-origin traffic. Direct browser access to the IP may not trust it, which is fine.
10. Check Docker Port Exposure
Run:
docker ps --format 'table {{.Names}}\t{{.Ports}}'
sudo ss -ltnp
Good:
running-booker-nginx-prod 0.0.0.0:80->80/tcp, 0.0.0.0:443->443/tcp
running-booker-app-prod 9000/tcp
running-booker-db-prod 5432/tcp
The app and database are fine only when they are not published to 0.0.0.0.
Bad:
0.0.0.0:5432->5432/tcp
0.0.0.0:6379->6379/tcp
0.0.0.0:9000->9000/tcp
0.0.0.0:1025->1025/tcp
0.0.0.0:8025->8025/tcp
0.0.0.0:2375->2375/tcp
Do not expose PostgreSQL, Redis, PHP-FPM, Mailpit, or the Docker API publicly.
11. Configure Hetzner Firewall
Best target state:
ALLOW tcp/22 from YOUR_PUBLIC_IP/32
ALLOW tcp/80 from Cloudflare IP ranges
ALLOW tcp/443 from Cloudflare IP ranges
DROP all
Cloudflare IP ranges:
If maintaining the full Cloudflare allowlist is too much during setup, use this temporary state:
ALLOW tcp/22 from YOUR_PUBLIC_IP/32
ALLOW tcp/80 from anywhere
ALLOW tcp/443 from anywhere
DROP all other inbound traffic
Then add the Nginx direct-IP block below.
This is not as strong as Cloudflare-only origin access, but it is much better than leaving SSH, database, mail, and Docker ports reachable.
12. Block Direct IP Requests in Nginx
If 80 and 443 are open while you are stabilizing the deployment, the raw IP should still not serve the application.
Add default server blocks before the real domain server blocks:
server {
listen 80 default_server;
listen [::]:80 default_server;
server_name _;
return 444;
}
server {
listen 443 ssl default_server;
listen [::]:443 ssl default_server;
server_name _;
ssl_certificate /etc/nginx/ssl/fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/privkey.pem;
return 444;
}
Restart:
docker compose -f docker-compose.prod.yml restart nginx
Test from outside the server:
curl -I http://YOUR_SERVER_IP
curl -k -I https://YOUR_SERVER_IP
curl -I https://example.com
Expected:
- direct IP does not serve the app
- domain works through Cloudflare
13. Block Outbound FTP
If the app does not need FTP, block it.
This matters if the previous incident involved outbound FTP scanning or brute force attempts.
sudo iptables -I OUTPUT -p tcp --dport 20 -j REJECT
sudo iptables -I OUTPUT -p tcp --dport 21 -j REJECT
Verify:
sudo iptables -S OUTPUT | grep -- '--dport 2'
Make it persistent later with your firewall tooling or provisioning scripts.
14. Configure Cloudflare WAF
In Cloudflare, enable:
- WAF Managed Rules
- Bot Fight Mode, if available
- Security Level: Medium
- Always Use HTTPS
- Full strict TLS
Add challenge or rate limiting rules for sensitive paths:
/login
/register
/admin*
/rb-control*
/submit-track
/submit-event
/api/*
The goal is to stop obvious automation before it reaches the application, PHP runtime, or database.
For admin paths, consider stronger controls:
- Cloudflare Access
- IP allowlist
- managed challenge
- country or ASN restrictions if they make sense for the business
15. Fix Production Environment Values
For Laravel-style apps, verify the production .env:
APP_ENV=production
APP_DEBUG=false
APP_URL=https://example.com
FRONTEND_URL=https://example.com
FRONTEND_ALLOWED_ORIGINS=https://example.com,https://www.example.com
SANCTUM_STATEFUL_DOMAINS=example.com,www.example.com
SESSION_DOMAIN=.example.com
LOG_LEVEL=warning
Avoid:
APP_DEBUG=true
APP_URL=http://localhost
FRONTEND_URL=http://localhost:5173
LOG_LEVEL=debug
Clear Laravel config after changes:
docker exec app-container php artisan optimize:clear
docker compose -f docker-compose.prod.yml restart app nginx
Rotate any secret that existed on the old host:
- database password
- app secrets
- mail credentials
- API keys
- admin passwords
- deploy keys
- SSH keys if there is any doubt
If a secret was pasted into a ticket, chat, or log while debugging, rotate it.
16. Final Checks
On the server:
sudo sshd -T | egrep 'permitrootlogin|passwordauthentication|pubkeyauthentication'
docker ps --format 'table {{.Names}}\t{{.Ports}}'
sudo ss -ltnp
pgrep -af 'perfcc|perfctl|bomb'
ss -plant | grep ':21'
From your laptop:
curl -I https://example.com
curl -I http://YOUR_SERVER_IP
curl -k -I https://YOUR_SERVER_IP
Good enough to leave running:
- SSH is key-only
- root SSH login is disabled
- SSH is limited to your IP
- only Nginx exposes public Docker ports
- the domain goes through Cloudflare
- direct IP does not serve the app
- no public database or development ports
- no suspicious malware process
- no outbound FTP traffic
- production env values are not local development values
What Still Comes Later
This baseline does not remove the need for application security work.
After the server is stable:
- review upload endpoints
- audit dependencies
- review admin users
- check queued jobs and scheduled commands
- rotate remaining third-party tokens
- enable monitoring and alerts
- make firewall rules persistent and documented
- shut down the old compromised server
The important part is sequence.
First, get a clean server online with the obvious holes closed. Then improve from there.
Security work is easier when the server is no longer actively bleeding.