VPS for Laravel: Specs, OS Choice, and Initial Hardening

 


Buying a VPS is the easy part. What usually causes the headache comes right after: the server is live, you have the IP, but there is no clear picture of what needs to be secured before any Laravel app lands on it. Plenty of people jump straight to installing Nginx and PHP, then realize later that root login with a password is still open, the firewall was never switched on, and nothing is blocking the bots hammering the login prompt.

This is part one of the Deploy Laravel to a VPS series. Its scope is deliberately narrow: a tidy server foundation. Not the app install — that comes in later parts. What you will do here: pick sensible specs, install Ubuntu LTS, create a non-root user, move login to SSH keys, turn on the firewall, install fail2ban, and set up swap. By the end, you have a server ready to receive Nginx and PHP-FPM without redoing any groundwork.

Pick realistic specs, not the cheapest or the biggest

The right VPS size for Laravel comes down to three things: how many PHP processes run at once, whether the database and Redis live on the same box, and how much traffic you expect in the first three months. For small to medium apps — an admin panel, an internal API, a company site with forms, a shop just getting started — a healthy starting point is 2 vCPUs, 4 GB of RAM, and 50 GB of SSD disk.

Why 4 GB instead of 2 GB? Because the same server usually runs Nginx, several PHP-FPM workers, a database, and a queue worker, each taking its own share of memory. On 2 GB, one worker spike or one heavy database query is enough to push the system into aggressive swapping, and response times suffer with it. Going the other way — 8 vCPUs and 16 GB for an app with a few dozen visitors a day — just burns monthly budget for no felt benefit.

A rough guide: a small app with its database on the same server starts at 2 vCPUs and 4 GB. If queue workers run constantly, Redis handles cache and queues, or regular data imports run, step up to 4 vCPUs and 8 GB. Once traffic reaches thousands of requests per hour and response times wobble, that is the time to split the database onto its own server or add workers — not to blindly raise specs. Always check what is actually full first: CPU, RAM, or disk. htop and df -h answer that in seconds, and the upgrade decision becomes a numbers call, not a gut call.

On region, pick one close to most of your users. For users in Indonesia, a Singapore region usually gives far better latency than Europe or the US. A few dozen milliseconds of difference shows up on every page that loads many assets and API calls.

Ubuntu LTS as the operating system

For this series, the OS is Ubuntu 24.04 LTS (Noble Numbat). The reasoning is practical: an LTS release gets five years of standard support, its repositories ship PHP 8.3, which is still current enough for Laravel, and nearly every server guide assumes Ubuntu's apt commands and file layout. Skipping non-LTS releases means you are not forced into a major upgrade every nine months just to stay on supported security patches.

One note on PHP: Laravel's deployment documentation requires PHP 8.2 at minimum, plus the Ctype, cURL, DOM, Fileinfo, Filter, Hash, Mbstring, OpenSSL, PCRE, PDO, Session, Tokenizer, and XML extensions. PHP 8.3 from Ubuntu 24.04 clears that floor, so there is no need to add a third-party repository just to get a new enough PHP. One thing is mandatory before installing anything: update the packages. In October 2026, Ubuntu shipped a PHP security fix (USN-8948-1) for the PHP2Shell flaws, and the fixed PHP 8.3 package is marked 8.3.6-0ubuntu0.24.04.13. Running apt upgrade before installing anything makes sure you do not install a still-vulnerable build.

Your first login to the server is usually as root with a password from the provider's panel. That is fine as a starting point, but it should not be the permanent state. Every step below runs from that root session, and ends with root login and password login switched off.

Step 1: Update the system and install the basics

The first command on a fresh server is always an update. Install nothing before the package lists are fresh:

apt update && apt upgrade -y
apt install -y curl wget git unzip htop ufw fail2ban software-properties-common

Expected result: the upgrade finishes without errors, and ufw --version and fail2ban-client --version print their version numbers. If some packages are held back, run apt list --upgradable and check whether they are kernel-related — a new kernel only takes effect after a reboot, which you can postpone until the whole foundation is done.

Set the timezone to Asia/Jakarta while you are here, so log timestamps are easy to read:

timedatectl set-timezone Asia/Jakarta
timedatectl

Expected result: the Time zone line shows Asia/Jakarta (WIB, +0700).

Step 2: Create a non-root user called deploy

Working day to day as root is like carrying the master key everywhere: one mistyped command can delete system files. The deploy user becomes your working account — the owner of deploys, the owner of the app directory, and eventually the only account allowed in over SSH.

adduser deploy
usermod -aG sudo deploy

adduser is interactive: it asks for a password for the new user and a few optional details (full name and the like) that you can leave blank with Enter. Then verify membership:

id deploy

Expected result: the output includes groups=...sudo.... The -a flag matters — without it, usermod -G sudo replaces the user's entire group list instead of adding to it.

Step 3: Move login to an SSH key

Passwords can be guessed by bots through repeated attempts. SSH keys cannot. If you do not have a key pair on your local machine yet, create one there (not on the server):

ssh-keygen -t ed25519 -C "deploy-vps-laravel"

Accept the default location (~/.ssh/id_ed25519) and set a passphrase — it is an extra layer in case the key file on your laptop ever leaks. Then send the public key to the server:

ssh-copy-id deploy@YOUR_SERVER_IP

Replace YOUR_SERVER_IP with your VPS IP. After that, test logging in as the new user from a fresh terminal, while keeping the old root session open:

ssh deploy@YOUR_SERVER_IP

If login succeeds without asking for the user password (only the key passphrase, if you set one), the key works. Do not close the old root session yet — it is your way back if anything goes sideways in the SSH hardening step.

If ssh-copy-id is not available on your machine, copy the contents of your local ~/.ssh/id_ed25519.pub into /home/deploy/.ssh/authorized_keys on the server manually, then lock down the permissions:

mkdir -p /home/deploy/.ssh
chmod 700 /home/deploy/.ssh
chmod 600 /home/deploy/.ssh/authorized_keys
chown -R deploy:deploy /home/deploy/.ssh

These permissions are not ceremony. SSH ignores an authorized_keys file with overly loose permissions, and you end up locked out with no clear error message.

Step 4: Tighten the SSH configuration

Now harden the SSH daemon itself. Open /etc/ssh/sshd_config in your editor:

nano /etc/ssh/sshd_config

Make sure these three lines exist and are not prefixed with #:

PermitRootLogin no
PasswordAuthentication no
PubkeyAuthentication yes

In plain terms: root may not log in over SSH at all, passwords are not accepted, only keys get in. Before restarting the service, check the syntax:

sshd -t

No output means the syntax is clean. If there is an error, fix it first — do not restart on a broken config. Then:

systemctl restart ssh

Expected result: systemctl status ssh shows active (running). Now open a new terminal and test two things: login as deploy (must succeed) and login as root (must be refused). Only after the deploy login is proven to work should you close the old root session. This order is what saves you from the classic incident: switching passwords off, then discovering the key was never installed properly.

A common mistake at this step is moving SSH to a random port on the assumption that it is "more secure". A changed port only quiets bot noise in the logs; it adds no meaningful security, and it gives you one more thing that can go wrong in the firewall, fail2ban, and deploy configuration later. Port 22 guarded by keys, a firewall, and fail2ban is enough for this foundation.

Step 5: Switch UFW on before enabling anything else

UFW is Ubuntu's built-in firewall. The rule is simple: deny all incoming connections by default, then open only what is needed. The order matters — allow SSH first, then enable the firewall. Reversing that order locks you out of your own server:

ufw allow OpenSSH
ufw enable
ufw status verbose

Expected result: status active, an OpenSSH rule listed as ALLOW, and default policies of deny (incoming) and allow (outgoing). The ufw allow OpenSSH command uses the OpenSSH app profile, which is equivalent to opening port 22/TCP. Later in this series, you will add Nginx Full (ports 80 and 443) in the next article — after Nginx is installed and proven to answer over HTTP.

The classic mistake: enabling UFW from inside an SSH session with no SSH rule, then panicking. If that happens, the only way back is the emergency console in the provider's panel. So make it a habit to check ufw status before closing any session after changing firewall rules.

Step 6: Install fail2ban to block repeated login attempts

The firewall limits which ports are open; fail2ban limits who gets to keep knocking. fail2ban reads authentication logs and temporarily bans IPs that fail login repeatedly. It was already installed in step 1. Never edit /etc/fail2ban/jail.conf directly — that file can be overwritten on package upgrades. Create the override file /etc/fail2ban/jail.local:

nano /etc/fail2ban/jail.local

Contents:

[DEFAULT]
bantime = 1h
findtime = 10m
maxretry = 5
banaction = ufw

[sshd]
enabled = true
port = 22
maxretry = 5
bantime = 1h

In plain terms: an IP that fails login 5 times within 10 minutes is banned for one hour, and the ban is applied through UFW so it shows up in ufw status. Enable it and check:

systemctl enable --now fail2ban
fail2ban-client status sshd

Expected result: the sshd jail status shows Currently failed and Currently banned (both may be zero on a fresh server — what matters is that the jail is active, not the numbers). To see banned IPs at any time: fail2ban-client status sshd, then ufw status.

One warning that often gets skipped: since password login is already off, password-guessing bots can never succeed — fail2ban here is noise reduction and a second layer, not the only guard. Never loosen the SSH rules on the grounds that "fail2ban is already there".

Step 7: Set up 2 GB of swap

Swap is emergency room on disk for when RAM runs out. On a 4 GB VPS, 2 GB of swap gives breathing room when a process occasionally peaks — Composer resolving a large dependency set, for example, or workers piling up. Swap is not a RAM replacement: if the server lives on swap permanently, the fix is more capacity, not a bigger swap file.

fallocate -l 2G /swapfile
chmod 600 /swapfile
mkswap /swapfile
swapon /swapfile

To make swap come back on every reboot, add this line to /etc/fstab:

/swapfile none swap sw 0 0

Then lower the kernel's eagerness to use swap so RAM stays preferred. Create /etc/sysctl.d/99-swappiness.conf containing:

vm.swappiness = 10

Apply it with sysctl -p /etc/sysctl.d/99-swappiness.conf, and verify:

swapon --show
free -h

Expected result: swapon --show lists /swapfile at 2 GB, and free -h shows a Swap line totaling 2.0 GB. If fallocate fails on some providers (there are VPS filesystems that do not support it), the fallback is dd if=/dev/zero of=/swapfile bs=1M count=2048 — slower, but the same result.

Step 8: Turn on automatic security updates

A good foundation goes stale within a month if nobody installs its security patches. Enable unattended-upgrades so security fixes install themselves:

apt install -y unattended-upgrades
dpkg-reconfigure -plow unattended-upgrades

The second command is interactive and asks whether automatic updates should be enabled — answer Yes. Verify the timer and configuration:

systemctl status apt-daily-upgrade.timer
cat /etc/apt/apt.conf.d/20auto-upgrades

Expected result: the timer is active, and the config file shows APT::Periodic::Update-Package-Lists "1"; and APT::Periodic::Unattended-Upgrade "1";. These automatic updates only install security patches from Ubuntu's repositories — they do not silently upgrade your PHP or Nginx versions — so they will not break the configuration you build in the next article.


Summary table

Foundation pieceDecisionKey command or file
Starting specs2 vCPUs, 4 GB RAM, 50 GB SSDUpgrade after measuring, not before
Operating systemUbuntu 24.04 LTS5 years standard support, PHP 8.3 included
Working userdeploy with sudoadduser deploy, usermod -aG sudo deploy
SSH loginEd25519 key, root and passwords off/etc/ssh/sshd_config, sshd -t, systemctl restart ssh
FirewallUFW active, only SSH openufw allow OpenSSH, ufw enable
Brute-force blockingfail2ban sshd jail, 1-hour bans/etc/fail2ban/jail.local, fail2ban-client status sshd
Swap2 GB, swappiness 10/swapfile, /etc/fstab, /etc/sysctl.d/99-swappiness.conf
UpdatesAutomatic security patchesunattended-upgrades, apt-daily-upgrade.timer

Final checklist

  • Login as deploy with an SSH key works from a fresh terminal; root login is refused.
  • ufw status verbose shows active status with the OpenSSH rule.
  • fail2ban-client status sshd shows an active jail.
  • swapon --show shows 2 GB of swap and free -h confirms it.
  • timedatectl shows the Asia/Jakarta zone.
  • The apt-daily-upgrade timer is active for security patches.
  • The old root session is closed only after all checks pass — not before.

A question worth answering at this point

Is 2 vCPUs and 4 GB enough when the database and Redis share the same server? For the early stage, yes — as long as you measure. If htop consistently shows RAM above 80 percent, or the load average stays above the number of vCPUs for a long time, that is a signal to add RAM, not swap. And if you plan to run a serious production database, split it onto a dedicated server as early as you can — the hardening foundation in this series stays the same on whichever server it runs.

The next article builds directly on this foundation: Nginx and PHP-FPM go in, the server block points at the Laravel public directory, and HTTPS from Let’s Encrypt is switched on. Without this foundation, all of that would sit on a server that anyone could still enter through root and a password.

Which foundation piece is most often skipped when setting up a fresh server — the firewall, fail2ban, or swap? Tell us in the comments, and mention the VPS specs you are running now so other readers can compare.

Baca juga: the step-by-step guide to setting up a first Ubuntu VPS and its basic hardening in VPS Pertama: Setup Ubuntu dan Hardening Awal, and how one VPS serves many services through Satu VPS Banyak Layanan: Nginx Reverse Proxy.

Komentar