A Laravel application that runs perfectly with php artisan serve can still be one misconfiguration away from trouble in production: a database port open to the internet, APP_DEBUG left on, an .env file readable over HTTP, a backup that has never been restored. Docker makes a clean setup easier to repeat, but it does not make it secure by itself. This guide walks through a production stack for one Laravel application on one server, layer by layer, with the settings that matter and the mistakes we see most often.
#The shape of the stack
A typical Laravel application in production needs several things: something that terminates HTTPS, a web server for static files, PHP-FPM for the application, a queue worker, the scheduler, a database and usually Redis. The safest arrangement has exactly one public entry point:
- A reverse proxy such as Caddy takes ports 80 and 443, gets and renews certificates automatically and adds HSTS.
- nginx sits behind it, serves
public/and the Vite build, and passes PHP requests to PHP-FPM. - The queue worker and the scheduler run from the same image as the application, so code and extensions are always identical.
- MySQL and Redis sit on an internal network with no published ports and no route to the internet.
#Lock the containers down
Containers share the host's kernel, so give each one as little as possible. In Compose that is a handful of keys:
services:
app:
build: .
read_only: true
tmpfs: [/tmp]
cap_drop: [ALL]
security_opt: ["no-new-privileges:true"]
volumes:
- storage:/var/www/html/storage
networks: [backend, data]
healthcheck:
test: ["CMD", "php-fpm-healthcheck"]
mysql:
image: mysql:8.4
networks: [data] # no "ports:" at all
networks:
backend: {}
data:
internal: true # no route to the internet
volumes:
storage: {}
A read-only file system with only storage/ and /tmp writable means an attacker who finds a file-upload bug cannot drop a PHP file next to your code. Dropping all Linux capabilities and forbidding privilege escalation removes most of what a compromised process could do. Use whatever health check fits your image; the point is that every service has one, so Docker restarts what is stuck.
#Do not trust the firewall to hide published ports
This catches experienced people. Docker writes its own iptables rules for every published port, and those rules are applied before a host firewall such as ufw sees the traffic. A ports: ["3306:3306"] line therefore opens MySQL to the internet even when ufw status says only 22, 80 and 443 are allowed. The fix is not a firewall rule; it is not publishing the port. Publish only the proxy, and if you need a database port for an SSH tunnel, bind it to 127.0.0.1:3306:3306.
#Laravel's own production settings
APP_ENV=productionandAPP_DEBUG=false. A debug page shows environment variables to anyone who triggers an error.- The
.envfile is never in the image (list it in.dockerignore) or in Git, and on the server it is readable only by its owner. - Behind a proxy, tell Laravel to trust it in
bootstrap/app.phpwith$middleware->trustProxies(at: '*'), so URLs and secure cookies use HTTPS. This is safe only when nothing but your proxy can reach the application. - Run
php artisan migrate --forceandphp artisan optimizeon each deploy. - Run the worker with limits:
php artisan queue:work --tries=3 --max-time=3600, so a stuck job or a memory leak does not last forever. Run the scheduler withphp artisan schedule:workin its own container.
#The web server: small rules, big effect
- Return 404 for dotfiles (
.env,.git) and for any PHP file other thanindex.php. - Add
X-Content-Type-Options,X-Frame-Options,Referrer-PolicyandPermissions-Policy, and hide server versions. - Rate-limit the login, registration and password-reset routes.
- Cache the hashed Vite files for a year; they change name when they change content.
#Harden the server underneath
Docker on a carelessly configured server is still a carelessly configured server. The minimum: SSH with keys only and no root login; a firewall that allows SSH, 80 and 443; fail2ban against password guessing; automatic security updates; Docker CE from Docker's own repository. Writing this as an Ansible playbook instead of a list of commands means the second server is configured exactly like the first.
#Backups you have actually restored
A backup is only a backup once you have restored it. A good setup for one server:
- Dump the database consistently:
mysqldump --single-transactionreads InnoDB tables without locking the application. - Include the uploads in
storage/app; a database without the files it points to is half a backup. - Encrypt before the archive leaves the server, for example
openssl enc -aes-256-cbc -pbkdf2 -pass env:BACKUP_PASSPHRASE, and keep the passphrase somewhere other than that server. - Read each archive back right after writing it, and store a checksum next to it.
- Rotate: for example 7 daily, 4 weekly and 6 monthly copies.
- Copy off-site: a backup on the same disk dies with the disk. rclone can copy to S3-compatible storage, Backblaze B2 or SFTP.
- Monitor: a ping to an uptime service after each successful run, and an alarm when the last good backup is too old.
- Test a restore into a new, empty database every few months, and compare row counts or checksums with production.
For more on keeping an application healthy after launch, see web application security basics and website maintenance after launch.
#Berthwell: this stack as files you can read
We packaged this setup as Berthwell: a Docker Compose and Ansible kit for one Laravel application on one server. Caddy with automatic HTTPS and HSTS, nginx, PHP-FPM 8.4, a queue worker and the scheduler from one image, MySQL 8.4 and Redis on an internal network, read-only containers without Linux capabilities, health checks and rotated logs, and only Caddy publishing a port. The nginx and PHP files carry the rules above. A backup service dumps the database and the uploads every night, encrypts them with AES-256, reads each one back, keeps 7 daily, 4 weekly and 6 monthly copies, can mirror them with rclone and report to a ping URL; a restore script loads them into a new database for checking. The Ansible playbook hardens SSH, sets up the firewall, fail2ban and automatic updates, installs Docker CE and runs the stack as a systemd service.
It is honest about its scope: one application, one server, MySQL only, no zero-downtime deploys. Its 44 automated tests validate the Compose file, lint the Dockerfiles, check the Caddy, nginx and PHP-FPM configuration, run the kit's own files against a real Laravel 13 application with MySQL 8.4 and Redis, and back up and restore a database with identical checksums. The images were not built with Docker in those tests, and the playbook was not run against a real server, which is why we say so. Berthwell is listed as Coming soon.
#Frequently asked questions
Is Docker more secure than installing PHP directly on the server?
Not automatically. Docker makes a locked-down setup easier to repeat and isolates services from each other, but a container running as root with a published database port is less safe than a careful plain install.
Why does ufw not block my published Docker port?
Docker adds its own iptables rules for published ports, which apply before ufw's. Do not publish internal ports at all, or bind them to 127.0.0.1.
How often should I test a restore?
At least every few months and after every change to the backup setup. A restore you have never tried is a hope, not a plan.
Can I run several Laravel applications on one server this way?
Yes, with one proxy in front of several stacks, but each application then needs its own networks, database credentials and backups. Berthwell is written for one application per server.
Comments
Comments
Be the first to leave a comment.
Leave a comment