Skip to content
PN Scripts

Security

A secure Laravel production stack with Docker and backups you can restore

  • PN Scripts
  • 7 min read

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=production and APP_DEBUG=false. A debug page shows environment variables to anyone who triggers an error.
  • The .env file 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.php with $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 --force and php artisan optimize on 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 with php artisan schedule:work in its own container.

#The web server: small rules, big effect

  • Return 404 for dotfiles (.env, .git) and for any PHP file other than index.php.
  • Add X-Content-Type-Options, X-Frame-Options, Referrer-Policy and Permissions-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:

  1. Dump the database consistently: mysqldump --single-transaction reads InnoDB tables without locking the application.
  2. Include the uploads in storage/app; a database without the files it points to is half a backup.
  3. 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.
  4. Read each archive back right after writing it, and store a checksum next to it.
  5. Rotate: for example 7 daily, 4 weekly and 6 monthly copies.
  6. Copy off-site: a backup on the same disk dies with the disk. rclone can copy to S3-compatible storage, Backblaze B2 or SFTP.
  7. Monitor: a ping to an uptime service after each successful run, and an alarm when the last good backup is too old.
  8. 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.

#Sources

Keep reading

Keep reading

Comments

Comments

Be the first to leave a comment.

Leave a comment

Next step

pnscripts.com/contact

Talk to the team

Ask about an article, or tell us about a project you want built. We reply within one business day.

Write to us

The PN Scripts family

Other PN Scripts sites

Hosting, games and the blog each have their own site, run by the same company.

  • pnscripts.com

    PN Scripts

    Software engineering

    Custom web, mobile, API and game development, plus our open-source products and plugins.

  • games.pnscripts.com

    Games

    Games and game servers

    The home for PN Scripts games: free browser games you can play right now, with game servers coming soon.

  • hosting.pnscripts.com

    Hosting

    Hosting and infrastructure

    Shared hosting, KVM VPS, dedicated servers and domains, from the same company that builds your project.

  • blog.pnscripts.com

    Blog

    Articles and field notes

    Practical articles on software development, hosting, open source and games, written by the people who build them.

    You are here