Deploying Laravel to Hetzner Cloud, from an empty project
Server types and locations, Cloud Firewall and UFW, cloud-init for PHP-FPM and Nginx, the database, Object Storage backups, snapshots and volumes.
Jacob Søndergaard ·
Most of deploying Laravel to Hetzner Cloud is deploying Laravel to Ubuntu, and the other posts in this series cover that part line by line. This one covers what is specific to Hetzner: choosing a server type and location, Hetzner’s Cloud Firewall next to UFW, a cloud-init file that prepares the server on first boot, where the database goes, backups to Hetzner Object Storage, and snapshots and volumes. Commands use Hetzner’s hcloud CLI; each has an equivalent in the Hetzner Console.
Choosing a server type and location
Hetzner sells cloud servers in a few families. The shared-vCPU ones are what most Laravel apps need:
- CX. Cost-optimised, shared Intel or AMD vCPUs. CX23 has 2 vCPUs, 4 GB of memory and 40 GB of disk.
- CAX. The same shapes on Ampere Arm CPUs. Ubuntu’s PHP, Composer and Node packages all exist for arm64; check any Composer or npm package that ships a native binary.
- CPX. Shared AMD vCPUs, sold in every location including Ashburn, Hillsboro and Singapore.
- CCX. Dedicated vCPUs, for steady high CPU load.
Since late 2025 which types you can order depends on the location, and the older names many tutorials use, such as cx22 and cpx11, stopped being orderable in the EU locations on 1 January 2026. List what is on offer now:
hcloud context create my-project # paste an API token with read and write access
hcloud ssh-key create --name laptop --public-key-from-file ~/.ssh/id_ed25519.pub
hcloud server-type list
hcloud location listFor location, put the server near your users and keep everything else in the same place. Hetzner Object Storage exists only in Falkenstein (fsn1), Nuremberg (nbg1) and Helsinki (hel1), and it does not charge for traffic from servers in the same EU network zone. Plans in Ashburn, Hillsboro and Singapore include less traffic than the same plans in the EU. A CX23 or CAX11 runs a typical Laravel app with its database, queue worker and scheduler on one box; move to a larger type when memory, not CPU, runs short.
Hetzner Cloud Firewall versus UFW
The Cloud Firewall filters traffic before it reaches the server. It is stateful, costs nothing, drops all inbound traffic that no rule allows, and can attach to servers by label, so a new server gets it from its first second. UFW runs on the server. Use both, for different reasons:
- The Cloud Firewall is where to limit SSH to your own address. A mistake there is fixed from the Console, and Hetzner’s browser console reaches the server without SSH either way.
- UFW covers what the Cloud Firewall does not. Hetzner’s firewall FAQ says it does not filter traffic inside private networks, so a database server on one relies on UFW. UFW’s rules also travel with the server’s disk into snapshots and copies.
hcloud firewall create --name web
hcloud firewall add-rule web --direction in --protocol tcp --port 22 --source-ips 203.0.113.7/32 --description ssh
hcloud firewall add-rule web --direction in --protocol tcp --port 80 --source-ips 0.0.0.0/0,::/0 --description http
hcloud firewall add-rule web --direction in --protocol tcp --port 443 --source-ips 0.0.0.0/0,::/0 --description https
hcloud firewall add-rule web --direction in --protocol icmp --source-ips 0.0.0.0/0,::/0 --description pingReplace 203.0.113.7 with your own address. Changes apply to new connections; an SSH session that is already open survives a rule that would now refuse it, so test from a new terminal.
cloud-init for the first boot
Hetzner’s Ubuntu images run cloud-init, and a file passed as user data at creation prepares the server before you log in. This one installs Nginx, PHP-FPM with the extensions Laravel uses, Composer and Node for the asset build, creates a deploy user with your key, adds 2 GB of swap and turns on UFW, fail2ban and unattended upgrades:
#cloud-config
# First boot of a Laravel server on Hetzner's ubuntu-26.04 image.
package_update: true
package_upgrade: true
package_reboot_if_required: true
packages:
- nginx
- php8.5-fpm
- php8.5-cli
- php8.5-bcmath
- php8.5-curl
- php8.5-intl
- php8.5-mbstring
- php8.5-mysql
- php8.5-pgsql
- php8.5-redis
- php8.5-sqlite3
- php8.5-xml
- php8.5-zip
- composer
- nodejs
- npm
- git
- unzip
- ufw
- fail2ban
- unattended-upgrades
users:
- name: deploy
groups: [sudo]
shell: /bin/bash
lock_passwd: true
ssh_authorized_keys:
- ssh-ed25519 AAAA... you@laptop
swap:
filename: /swapfile
size: 2G
runcmd:
- ufw default deny incoming
- ufw default allow outgoing
- ufw allow 22/tcp
- ufw allow 80/tcp
- ufw allow 443/tcp
- ufw --force enable
- systemctl enable --now fail2banPHP 8.5 is the version in Ubuntu 26.04’s own archive, so no third-party apt source is needed. On the ubuntu-24.04 image use 8.3, or add a PHP repository for newer versions. package_reboot_if_required reboots once if the upgrade installed a new kernel, which is the cheapest moment for it.
The file sets no password. cloud-init fetches user data from Hetzner’s metadata service at 169.254.169.254, which answers any process on the server, so keep secrets and password hashes out of it. Set the deploy password for sudo after the first boot instead. Create the server:
hcloud server create --name app-1 --type cx23 --image ubuntu-26.04 --location nbg1 \
--ssh-key laptop --firewall web --user-data-from-file cloud-init.yamlhcloud server ip app-1
ssh root@203.0.113.10 cloud-init status --wait
ssh -t root@203.0.113.10 passwd deploy
ssh deploy@203.0.113.10 sudo whoamicloud-init status --wait returns when the first boot has finished. cloud-init status --long lists errors if something failed, and the full log is /var/log/cloud-init-output.log. Once deploy can log in and use sudo, turn off root login and password authentication as described in hardening a new Ubuntu server. cloud-init only runs this file on the first boot; editing it later changes nothing on an existing server.
PHP-FPM and Nginx
From here the server is a plain Ubuntu machine, and the rest of the stack is the same as on any provider. Each site gets its own Unix account and its own PHP-FPM pool, and Nginx serves the release’s public/ directory through it. The server block, the pool and the reason for each line are in the Nginx configuration for Laravel.
The database on the same box, or a second one
On one server, install PostgreSQL (or MySQL) next to the app. It listens on localhost only, and nothing about it is Hetzner-specific:
apt-get install -y postgresql
sudo -u postgres psql -c "CREATE ROLE example LOGIN PASSWORD 'change-me'" -c "CREATE DATABASE example OWNER example"Move it to a second server when the database needs its own memory or more than one app server talks to it. Put both servers on a Hetzner private network, so the database is never reachable from the internet:
hcloud network create --name private --ip-range 10.0.0.0/16
hcloud network add-subnet private --type cloud --network-zone eu-central --ip-range 10.0.0.0/24
hcloud server attach-to-network app-1 --network private --ip 10.0.0.2
hcloud server attach-to-network db-1 --network private --ip 10.0.0.3On the database server, listen on the private address, allow the app server’s address in pg_hba.conf, and open the port in UFW for that one address. The Cloud Firewall does not see this traffic, so UFW is the only filter on it:
sudo -u postgres psql -c "ALTER SYSTEM SET listen_addresses = 'localhost,10.0.0.3'"
echo "host example example 10.0.0.2/32 scram-sha-256" >> /etc/postgresql/18/main/pg_hba.conf
systemctl restart postgresql
ufw allow from 10.0.0.2 to any port 5432 proto tcpThe database server needs no rules for 80 and 443. In Laravel’s .env, DB_HOST becomes 10.0.0.3.
First deploy and the release layout
The deploy itself is the same as on any Ubuntu server. In order:
- The site account, the
shared/directory and.env, a deploy key for your repository, and a deploy script that builds each release in its own directory and switches a symlink: zero-downtime Laravel deploys. - The PHP-FPM pool and the Nginx server block: Nginx configuration for Laravel.
- A certificate: Let’s Encrypt with Nginx, once DNS points at the server’s IP.
The whole sequence was run on a server prepared by the cloud-init file above, against a new laravel/laravel app with PostgreSQL on the same box. One thing failed on the first try: laravel/laravel ships without a package-lock.json, and npm ci refuses to run without one. Run npm install once locally and commit the lock file.
Queue worker and scheduler
Queue workers run as systemd services that restart on every deploy, described in queue workers and Horizon with systemd. The scheduler is one crontab line for the site account, in the Laravel scheduler post. Neither depends on Hetzner, with one exception covered under snapshots below.
Backups to Hetzner Object Storage
Hetzner Object Storage speaks the S3 API, so any S3 client works. Create a bucket and an access key in the Hetzner Console; keys cannot be created through the API. A key covers every bucket in its Hetzner project, so a backup bucket in its own project limits what a leaked key exposes. The endpoint is the location’s name under your-objectstorage.com, and the region is the location:
[hetzner]
type = s3
provider = Other
access_key_id = YOUR_ACCESS_KEY
secret_access_key = YOUR_SECRET_KEY
endpoint = nbg1.your-objectstorage.com
region = nbg1
acl = privateapt-get install -y rclone
install -d -m 700 /root/.config/rclone
nano /root/.config/rclone/rclone.conf # the block above
chmod 600 /root/.config/rclone/rclone.conf
rclone lsd hetzner:
rclone copy /var/backups/example/ hetzner:example-backups/db/With a wrong key, rclone lsd answers InvalidAccessKeyId with status 403, which at least proves the endpoint and region are right. A single upload is limited to 5 GB, and rclone switches to multipart uploads for large files by itself. The dump script, retention and a tested restore are in backing up MySQL and PostgreSQL to S3; point it at this remote.
Snapshots and volumes
A snapshot is an image of the server’s disk that stays until you delete it. Hetzner’s backups are the scheduled version: daily images, seven kept, enabled per server. Take a snapshot before anything you might want to undo, such as a PHP or Ubuntu upgrade. Shutting down first means every write is in the image; a snapshot of a running server is what a power cut would leave behind, which databases survive the way they survive a crash.
hcloud server shutdown --wait app-1
hcloud server create-image --type snapshot --description "before PHP upgrade" app-1
hcloud server poweron app-1
hcloud image list --type snapshot
hcloud server rebuild --image 123456789 app-1 # app-1 back to the snapshot
hcloud server enable-backup app-1 # daily images, seven kept
hcloud server create --name app-2 --type cx33 --image 123456789 --location nbg1 \
--ssh-key laptop --firewall web # a copy of app-1A server created from a snapshot boots with everything the original had running: the queue worker, the scheduler’s crontab, the same .env. If both servers point at the same database and queue, two workers process the same jobs and every scheduled task runs twice. Stop the worker and remove the crontab line on the copy before it goes live, or give it its own .env. The copy also keeps the original’s SSH host keys.
A volume is block storage that you attach to one server at a time and can move to another in the same location. Neither snapshots nor backups include volumes, and volumes have no snapshots of their own. Volumes grow but cannot shrink. For Laravel, the useful place for one is storage/, so uploads survive rebuilding or replacing the server:
hcloud volume create --name uploads --size 20 --server app-1 --format ext4
hcloud volume describe uploads # "Linux Device" is the path to use belowDEV=/dev/disk/by-id/scsi-0HC_Volume_12345678
install -d /mnt/uploads
echo "UUID=$(blkid -s UUID -o value $DEV) /mnt/uploads ext4 defaults,nofail 0 2" >> /etc/fstab
systemctl daemon-reload
mount /mnt/uploads
rsync -a /var/www/example.com/shared/storage/ /mnt/uploads/storage/
mv /var/www/example.com/shared/storage /var/www/example.com/shared/storage.old
ln -s /mnt/uploads/storage /var/www/example.com/shared/storage
systemctl reload php8.5-fpmThe fstab line uses the filesystem’s UUID and nofail, so the volume mounts again after a reboot and a detached volume never stops the server from booting. Release directories already link storage to shared/storage, which now points at the volume; delete storage.old once the site works. After growing a volume, grow the filesystem with resize2fs.
Volumes are stored on three machines, which protects against a disk failing, not against deleting a file. Copy /mnt/uploads to Object Storage with the same rclone remote.
How Provisore does this on Hetzner
Connect a Hetzner Cloud API token with read and write access and Provisore creates the server for you, passing its bootstrap through cloud-init. Provisioning then adds a deploy user, swap, UFW for 22, 80 and 443, fail2ban and unattended upgrades, Nginx and certbot, and hardens SSH last, once the deploy user’s login works. On servers it created, snapshots and volumes run through Hetzner’s API: a snapshot of a running server flushes writes first, a copy made from one gets new host keys and its own credentials, and volumes are formatted, mounted by UUID with nofail and resized from the app. Object storage connects to a Hetzner key you create in the Console, and scheduled backups upload database dumps and site files to any S3-compatible bucket, Hetzner’s included. Laravel sites get their own account, PHP-FPM pool, scheduler entry and zero-downtime deploys.