Deploy Laravel to Hetzner
Follow this on your own server. Start free, no card needed
php artisan serve is fine on your laptop. In production, a Laravel app needs a real web server, a database, a queue worker that comes back after a crash, the scheduler running every minute, and HTTPS. This guide builds all of that on one Hetzner Cloud server with Docker Compose, using FrankenPHP, the application server Laravel’s own deployment docs list next to Nginx. At the end you’ll have the app live on your domain, a three-command deploy routine, and a database backup you’ve actually restored.
What you’ll need
- A Hetzner Cloud server. The cost-optimized CX23 (2 vCPU, 4 GB of memory, 40 GB NVMe, 20 TB of traffic) is €5.99 a month excluding VAT, with an IPv4 address included. It runs one Laravel app with its MySQL database. The CX33 (4 vCPU, 8 GB, 80 GB) is €8.99 a month if the queue does heavy work. Prices are from Hetzner’s cost-optimized plans page.
- A domain whose DNS you can edit.
- A Hetzner Cloud account with your SSH key uploaded.
- A Laravel app in a Git repository. This guide uses Laravel 13, which requires PHP 8.3 or newer, and runs it on PHP 8.4 with the official
dunglas/frankenphp:1-php8.4image.
Step 1: Create the server and point DNS at it
In the Hetzner Cloud Console, open your project and choose Add Server. Pick a location (Nuremberg, Falkenstein and Helsinki keep the data in the EU), Ubuntu 24.04, the CX23 type under cost-optimized, and your SSH key. Create it and copy its IPv4 address.
At your DNS provider, create an A record for your domain (for example app.example.com) pointing at that address. Do it now, because FrankenPHP requests the HTTPS certificate the moment it starts in Step 6, and it can only succeed once the name resolves to this server.
Connect:
ssh root@your-server-ip
You should get a root@ prompt without a password, since your key was added when the server was created.
Step 2: Install Docker
curl -fsSL https://get.docker.com | sh
docker compose version
Docker’s official convenience script installs the engine and the Compose plugin. You should see a Compose version printed.
Step 3: Write the Dockerfile
FrankenPHP is a PHP application server built on Caddy. One container serves PHP and your static files and gets its own Let’s Encrypt certificate, so you don’t need a separate Nginx and PHP-FPM pair. The image is based on the official PHP image, and its default site serves the public/ directory of /app, which is exactly where Laravel’s front controller lives.
Add this Dockerfile to the root of the repository:
FROM node:24-slim AS assets
WORKDIR /app
COPY package.json package-lock.json* ./
RUN if [ -f package-lock.json ]; then npm ci; else npm install; fi
COPY . .
RUN npm run build
FROM dunglas/frankenphp:1-php8.4
RUN install-php-extensions pdo_mysql intl zip opcache pcntl bcmath \
&& cp "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
WORKDIR /app
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-scripts --no-autoloader --prefer-dist
COPY . .
COPY --from=assets /app/public/build ./public/build
RUN composer dump-autoload --optimize --no-dev
What each part does:
- The
assetsstage builds your Vite assets intopublic/build, so the production image ships compiled CSS and JavaScript. Commit apackage-lock.jsonso the install is repeatable. install-php-extensionsis a helper included in the FrankenPHP image. Laravel’s documented requirements (Ctype, cURL, DOM, Fileinfo, Filter, Hash, Mbstring, OpenSSL, PCRE, PDO, Session, Tokenizer and XML) are already in the base image; this adds the MySQL driver and the extensions most apps and queue workers use.- Copying
php.ini-productioninto place switches PHP to its production defaults. - Composer installs dependencies before the rest of the code is copied, so Docker reuses that layer when only your code changes.
Add a .dockerignore next to it, so local dependencies, build output and your .env stay out of the image:
.env
.git
node_modules
vendor
public/build
storage/logs/*
storage/framework/cache/data/*
storage/framework/sessions/*
storage/framework/views/*
Step 4: Write the Compose file
The app needs four long-running processes: the web server, a queue worker, the scheduler and MySQL. The first three run the same image with a different command. Add compose.yaml:
x-laravel: &laravel
build: .
image: laravel-app
restart: unless-stopped
env_file: .env
volumes:
- storage:/app/storage
depends_on:
db:
condition: service_healthy
services:
app:
<<: *laravel
ports:
- "80:80"
- "443:443"
- "443:443/udp"
volumes:
- storage:/app/storage
- caddy-data:/data
- caddy-config:/config
queue:
<<: *laravel
command: php artisan queue:work --tries=3
healthcheck:
disable: true
scheduler:
<<: *laravel
command: php artisan schedule:work
healthcheck:
disable: true
db:
image: mysql:8.4
restart: unless-stopped
environment:
MYSQL_DATABASE: ${DB_DATABASE}
MYSQL_USER: ${DB_USERNAME}
MYSQL_PASSWORD: ${DB_PASSWORD}
MYSQL_RANDOM_ROOT_PASSWORD: "yes"
volumes:
- db-data:/var/lib/mysql
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "--silent"]
interval: 5s
timeout: 5s
retries: 30
volumes:
storage:
db-data:
caddy-data:
caddy-config:
A few details are doing real work here:
condition: service_healthymakes the PHP containers wait until MySQL accepts connections, not just until its container exists. The health check connects over127.0.0.1, which only succeeds once MySQL has finished its first-run initialization.php artisan schedule:workruns Laravel’s scheduler in the foreground and invokes it every minute, so the container needs no cron daemon.- The FrankenPHP image ships a health check that queries its built-in web server. The queue and scheduler containers don’t run that server, so their health checks are turned off rather than reported as failing.
storageis shared by the three PHP containers, so logs and uploaded files on the local disk are the same everywhere.caddy-dataholds the certificates, so they survive every redeploy.- MySQL publishes no port. Only the app container can reach it, by the host name
db.
Step 5: Clone the code and create .env
On the server:
git clone https://github.com/you/your-app.git /opt/your-app
cd /opt/your-app
For a private repository, add a read-only deploy key to it first, or clone over HTTPS with a token. Laravel’s default .gitignore already excludes .env, so the file you create now stays on the server across deploys:
APP_NAME=YourApp
APP_ENV=production
APP_KEY=
APP_DEBUG=false
APP_URL=https://app.example.com
SERVER_NAME=app.example.com
LOG_CHANNEL=stderr
DB_CONNECTION=mysql
DB_HOST=db
DB_PORT=3306
DB_DATABASE=laravel
DB_USERNAME=laravel
DB_PASSWORD=replace-with-a-long-random-password
SERVER_NAME tells FrankenPHP which domain to serve and request a certificate for. LOG_CHANNEL=stderr sends Laravel’s logs to docker compose logs. Generate the database password with openssl rand -hex 24, then protect the file with chmod 600 .env. Compose reads the same file to fill ${DB_DATABASE} and the other values for MySQL.
Laravel needs an encryption key. Build the image and let Laravel generate one:
docker compose build
docker compose run --rm --no-deps app php artisan key:generate --show
You should see a value starting with base64:. Paste it after APP_KEY= in .env. Keep a copy somewhere safe: if you lose it, encrypted data and existing sessions can’t be read.
Step 6: Start everything and run the migrations
docker compose up -d
docker compose exec app php artisan migrate --force
docker compose exec app php artisan optimize
--force is required because Laravel asks for confirmation before migrating in production. optimize caches configuration, events, routes and views, which Laravel’s deployment docs recommend on every deploy.
Check the containers:
docker compose ps
You should see app, queue, scheduler and db with a status of Up, and db marked healthy. Open https://app.example.com: the app loads over HTTPS with a valid certificate. Laravel’s built-in health route should answer too:
curl -I https://app.example.com/up
You should see HTTP/2 200.
The queue and scheduler containers may restart a few times before the migrations finish, because the database tables they use don’t exist yet. That’s expected, and they settle once migrate has run.
Step 7: Deploy new versions
Each release:
cd /opt/your-app
git pull
docker compose up -d --build
docker compose exec app php artisan migrate --force
docker compose exec app php artisan optimize
Compose rebuilds the image and recreates the three PHP containers, so the queue worker and scheduler pick up the new code. That’s what Laravel’s docs mean when they say long-running processes must be restarted after a deploy. MySQL keeps running, and the certificates stay in their volume. The site is unavailable for the few seconds the app container takes to restart.
To roll back, git checkout the previous commit and run the same commands. If the release included a migration, roll it back first with docker compose exec app php artisan migrate:rollback --force, while the new code is still running.
Step 8: Back up the database
MySQL holds the only data you can’t rebuild from Git. Save this as /opt/your-app/backup.sh:
#!/usr/bin/env bash
set -euo pipefail
cd /opt/your-app
mkdir -p backups
docker compose exec -T db sh -c 'exec mysqldump --no-tablespaces --single-transaction -u "$MYSQL_USER" -p"$MYSQL_PASSWORD" "$MYSQL_DATABASE"' | gzip > "backups/db-$(date +%F).sql.gz"
find backups -name 'db-*.sql.gz' -mtime +14 -delete
--single-transaction takes a consistent snapshot without locking InnoDB tables, and --no-tablespaces lets the app’s own database user run the dump without extra privileges. Make it executable and schedule it with crontab -e:
chmod +x /opt/your-app/backup.sh
30 3 * * * /opt/your-app/backup.sh >> /var/log/your-app-backup.log 2>&1
A backup on the same disk doesn’t survive losing the server, so copy the backups/ folder somewhere else as well: Hetzner Object Storage, another machine, or any S3-compatible bucket. Then prove the dump works by restoring it into the database:
gunzip -c backups/db-YYYY-MM-DD.sql.gz | docker compose exec -T db sh -c 'exec mysql -u "$MYSQL_USER" -p"$MYSQL_PASSWORD" "$MYSQL_DATABASE"'
Replace YYYY-MM-DD with the date of the file. This replaces the tables with the backup’s contents, so try it right after taking a backup, or against a copy of the stack. You should see no errors, and the app should work as before.
The shortcut: do it with Railyard
Everything above is a recipe Railyard runs for you, without the Dockerfile, the Compose file or the backup script. See Deploy Laravel to Hetzner for the provider walkthrough.
- In the Hetzner Cloud Console, open your project, then Security, then API tokens, and generate a token with Read & Write permission.
- In Railyard, go to Servers → Add a server → Create on your cloud, pick Hetzner, a location and a size. The server is created in your own Hetzner account, and Hetzner bills you directly with no markup. You can also connect a server you already have: Ubuntu 22.04 or 24.04, passwordless
sudo, ports 22, 80 and 443 open. - Deploy the repository, or open
https://app.railyard.run/deploy?repo=https://github.com/you/your-app.gitwith your own repository URL.
Railyard runs php artisan migrate --force before every release. It wires DB_HOST, DB_DATABASE and the other database variables from your .env.example to the database it provisions, generates APP_KEY once in Laravel’s base64 format, and sets APP_URL to the app’s address. php artisan queue:work is offered as a worker, or horizon when laravel/horizon is installed. Databases get scheduled backups that are verified by restoring them, with one-click restore; see Databases and backups.
The Free plan covers one server plus a database server and three apps, with no card required. Pro is $19 a month per team and Team is $49 a month per team. The Laravel docs list the detection rules.
Troubleshooting
bind: address already in use on port 80 or 443. Something else on the server holds the port, often an Nginx or Apache installed earlier. sudo ss -tlnp | grep -E ':(80|443) ' shows which process. Stop and disable it, for example sudo systemctl disable --now nginx, then run docker compose up -d.
No certificate, and the app logs ACME errors. DNS doesn’t point at the server yet, SERVER_NAME doesn’t match the domain, or a firewall blocks ports 80 and 443. dig +short app.example.com must return the server’s IP. If you added a Hetzner Cloud Firewall, allow inbound TCP 80 and 443 (and UDP 443 for HTTP/3). The server retries on its own once DNS is right.
SQLSTATE[HY000] [2002] or Access denied from MySQL. If it’s a connection error, check DB_HOST=db. If access is denied, the password in .env changed after MySQL’s first start: MySQL only reads MYSQL_USER and MYSQL_PASSWORD when it initializes an empty data volume. Put the original password back, or change it inside MySQL.
Links and redirects use http:// or the wrong host. APP_URL is wrong, or the configuration cache holds an old value. Fix .env, then run docker compose up -d and docker compose exec app php artisan optimize so the containers and the cache pick it up.
Vite manifest not found at: /app/public/build/manifest.json. The compiled assets aren’t in the image. Check that npm run build succeeded in the assets stage (docker compose --progress plain build app shows its output) and that your Vite config still writes to public/build, then rebuild.
Permission denied writing to storage. The FrankenPHP image runs as root by default, so this usually means files copied into the volume by hand, or an image changed to run as another user. Make the volume writable for the user PHP runs as, for example docker compose exec app chown -R www-data:www-data storage if you switched to www-data.
The build is killed while compiling assets or installing dependencies. A CX23 has 4 GB of memory, which is usually enough, but a large front-end build can exhaust it alongside a running stack. Add a 2 GB swap file (fallocate -l 2G /swapfile && chmod 600 /swapfile && mkswap /swapfile && swapon /swapfile), or move up to a CX33.
FAQ
Can I use Nginx and PHP-FPM instead? Yes. Laravel’s deployment docs include an Nginx configuration for PHP-FPM. You’d run a PHP-FPM image, an Nginx container with that configuration, and something to handle certificates. FrankenPHP does all three in one container, which is why this guide uses it.
What about Laravel Octane? FrankenPHP is one of Octane’s supported servers. Install laravel/octane, run php artisan octane:install --server=frankenphp, and start the app container with php artisan octane:frankenphp. Read Octane’s documentation first: it keeps the app in memory between requests, which changes how you have to handle state.
Should I use Postgres instead of MySQL? Either works with Laravel. Swap the db service for the official Postgres image, set DB_CONNECTION=pgsql and DB_PORT=5432, and install the pdo_pgsql extension instead of pdo_mysql.
Where do uploaded files go? With the default local and public disks, into storage/app, which lives in the storage volume. Back that volume up too, or move uploads to S3-compatible object storage, which also lets you add a second server later.
Do I need Redis? Not to start. This setup uses Laravel 13’s defaults, which keep the cache, sessions and queue in the database. Add a Redis service when you move to Horizon or need a faster cache.
That’s the whole stack: one image for the web server, the worker and the scheduler, MySQL beside them, and certificates handled for you. After the first setup, every release is a git pull, one Compose command and a migration.
Why people pick Railyard for this
- Your own cloud account. Deploy to DigitalOcean, Hetzner, Vultr, AWS, OVHcloud, or any Linux server over SSH, billed directly by your provider with no markup.
- One flat price per team. Free is free forever: one server plus a database server, three apps, no card. Pro is $19 a month for up to five servers and unlimited apps. Team is $49 a month with unlimited servers. No per-app, per-deploy, per-seat or per-gigabyte charges.
- One-click templates. Deploy open-source apps, like databases, analytics and dev tools, to your own server in one click.
- Managed databases. Postgres, MySQL and Redis with scheduled, restore-verified backups and point-in-time recovery.
- Push-to-deploy. Deploy from GitHub, GitLab or the CLI, with automatic HTTPS and zero-downtime releases.