Profile    Mohammed Shiroz Status   Loading  
Logo
Share This
Back to blog
Filter by:
Tags
//Article title

Hardening a Linux Server for a Laravel App: A Practical Checklist

About Post

Spin up a fresh server with a public IP address, then look at its SSH log an hour later. You'll find login attempts for root, admin, test and ubuntu from addresses all over the world. Nobody is targeting you personally. Bots simply scan the whole internet, all the time.

That's why hardening isn't something you do "later, once the app is live". It's the first hour of a server's life. The good news is that the basics are well understood, mostly boring, and take less time than setting up your deploy script.

Here's the checklist I follow for a Laravel app on Ubuntu 24.04 LTS with Nginx and PHP-FPM. Treat the commands as a simplified starting point and adapt them to your setup.

Step 1: patch everything, then keep patching

Most real-world compromises use known vulnerabilities that already have a fix. So update first, and make security updates automatic:

sudo apt update && sudo apt upgrade -y
sudo apt install unattended-upgrades
sudo dpkg-reconfigure --priority=low unattended-upgrades

Unattended upgrades install security updates for you. Some updates (like kernel patches) need a reboot to take effect, so check for /var/run/reboot-required now and then, or schedule a maintenance window.

Step 2: a normal user and SSH keys only

Don't work as root. Create a user with sudo rights and copy your public key to it:

sudo adduser deploy
sudo usermod -aG sudo deploy

# from your own machine:
ssh-copy-id deploy@your-server-ip

Then turn off password logins and direct root logins. On Ubuntu 24.04, put your settings in a drop-in file rather than editing the main config:

# /etc/ssh/sshd_config.d/00-hardening.conf
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes

Here's the gotcha that catches people: for most SSH settings, the first value found wins, and drop-in files are read in alphabetical order. Some cloud images ship a file like 50-cloud-init.conf that sets PasswordAuthentication yes. Name your file with a 00- prefix so it's read first, then confirm the result instead of assuming:

sudo sshd -t                     # check the config for errors
sudo systemctl restart ssh
sudo sshd -T | grep -Ei 'passwordauth|permitroot'

Don't lock yourself out: keep your current SSH session open, and test the new settings by logging in from a second terminal. Only close the first one when the second works.

Step 3: a firewall that says no by default

A web server needs exactly three ports open to the world: SSH, HTTP and HTTPS. Everything else (MySQL, Redis, your queue dashboard) should be unreachable from outside.

sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow OpenSSH
sudo ufw allow 'Nginx Full'
sudo ufw enable
sudo ufw status verbose

Two things worth knowing:

  • Bind services to localhost too. MySQL and Redis should listen on 127.0.0.1, not 0.0.0.0. Then even a firewall mistake doesn't expose them. Check what's listening with sudo ss -tulpn.
  • Docker can bypass ufw. Ports published with -p 3306:3306 are added to iptables by Docker directly, so they can be reachable even when ufw says otherwise. Publish to 127.0.0.1:3306:3306 if a container only needs local access.

On AWS, security groups give you a second firewall in front of the server. Use both: security groups to allow only what's needed, ufw as the safety net, and keep databases like RDS in private subnets.

Step 4: fail2ban for the noisy neighbours

With password logins off, brute-force attempts can't succeed, but they still fill your logs and waste resources. fail2ban watches the logs and temporarily bans IPs that keep failing:

sudo apt install fail2ban
sudo fail2ban-client status sshd

On Ubuntu the SSH jail is enabled out of the box. To tune ban times or add jails (for example, for repeated failed logins on your app's login page), create /etc/fail2ban/jail.local rather than editing the default files, which get overwritten on updates.

Step 5: least privilege for the app itself

This is the step most guides skip, and it matters a lot for PHP apps. If an attacker finds a hole in your application, the damage they can do depends on what the PHP process is allowed to touch.

  • Code owned by the deploy user, not the web server. PHP-FPM runs as www-data and should be able to read the code, but only write to storage and bootstrap/cache.
  • Only public/ is the web root. Your Nginx root points to /var/www/app/public, never the project folder. Otherwise .env may be one URL away.
  • Protect .env. Readable by the app, not by every user on the box.
cd /var/www/app
sudo chown -R deploy:www-data .
sudo find . -type f -exec chmod 644 {} \;
sudo find . -type d -exec chmod 755 {} \;
sudo chmod -R g+w storage bootstrap/cache
sudo chmod 640 .env

And in .env, the two lines that should never be wrong in production: APP_ENV=production and APP_DEBUG=false. A debug error page in production can reveal file paths, queries and configuration to anyone who triggers an exception.

While you're there, hide version banners: server_tokens off; in Nginx and expose_php = Off in php.ini. It won't stop a determined attacker, but there's no reason to advertise.

Step 6: HTTPS everywhere

Let's Encrypt certificates are free, and Certbot sets them up and renews them automatically:

sudo apt install certbot python3-certbot-nginx
sudo certbot --nginx -d example.com -d www.example.com
sudo certbot renew --dry-run

Redirect all HTTP traffic to HTTPS, and once you're sure everything works over HTTPS, add a Strict-Transport-Security header so browsers stop trying plain HTTP at all. If the app is behind a load balancer, configure Laravel's trusted proxies so it knows the original request was HTTPS.

Step 7: backups you have actually restored

Hardening reduces the chance of a bad day. Backups decide how bad it gets. A backup plan for a typical Laravel app:

  • Database dumps on a schedule (mysqldump --single-transaction for InnoDB, or managed snapshots if you use RDS).
  • Uploaded files, unless they already live in S3 with versioning on.
  • Stored off the server, with credentials that can write new backups but can't delete old ones. If an attacker gets the server, they shouldn't get your backups too.
  • Restored for real, regularly, into a test environment. A backup you've never restored is a hope, not a backup.

The checklist

  • System updated, unattended security upgrades on.
  • Non-root sudo user; SSH keys only; root login off; verified with sshd -T.
  • Firewall denies by default; only SSH, HTTP and HTTPS open; databases on localhost or private subnets.
  • fail2ban running.
  • App code read-only to PHP except storage and bootstrap/cache; web root is public/; .env locked down; debug off.
  • HTTPS with automatic renewal.
  • Off-server backups, tested by restoring.

For deeper reading, Laravel's deployment documentation covers the Nginx config and optimisation steps.

Which item on this list do you see skipped most often? My guess is the restore test, because it's the only one that feels like extra work until the day it isn't.

Comments (0)
Leave your review

Thanks for your valuable comments. Your comments has been updated and appreciate your getting in touch...

01. About Shiroz

Mohammed Shiroz

Hi, I'm Mohammed Shiroz, a software engineer and AI enthusiast from Sri Lanka who turns ideas into intelligent, real-world solutions. With over 9 years of hands-on experience, I currently lead real estate ERP development at Kate Group, a...

03.My Projects

04. Categories

Ready To order Your Project ?

Get in Touch
Close