I set up a new server from scratch for my personal projects: a LEMP stack on Ubuntu 24.04, all over SSH. There are a thousand tutorials on how to install Linux, Nginx, MariaDB, and PHP, and I’m not going to write the thousand-and-first. What almost all of them miss is the reasoning behind each decision, the actual hardening, and the details that break after you thought you were done. That’s what I’m covering here.

The “how to install” part is everywhere. The judgment to not leave it half-finished isn’t.

Why LEMP in 2026 (and why Ubuntu 24.04 LTS)

LEMP instead of LAMP for one concrete reason: Nginx. I’ve done my share of fighting Apache and its .htaccess files, and on my last project I’d already moved everything to Nginx. The difference that matters to me isn’t just performance under load; it’s that all the configuration lives in one place, declared explicitly, instead of scattered across .htaccess files that anyone can drop into a folder and silently change how your server behaves. More control, fewer surprises.

I picked Ubuntu 24.04 LTS (Noble Numbat) for how boring it is, and I mean that as praise. An LTS gives me five years of security updates without having to migrate across a major version. On a server I want to forget about and have just work, that stability is worth more than having the newest packages.

The stack at a glance: everything from the repo, and why

Here’s the first decision a tutorial rarely justifies: I kept everything from Ubuntu’s default repository. Nginx, MariaDB 10.11 LTS, and PHP 8.3-FPM, the versions 24.04 ships, with nothing pulled from upstream.

It’s tempting to add the nginx.org repo for mainline, or a PPA for the latest PHP. I didn’t, and on purpose. When you rely on Ubuntu’s packages, security updates come through the official channel: one apt upgrade and you’re done, with no third-party repos to maintain or that end up orphaned. The trade-off is not having the newest version of everything. For a production server I want to keep stable, that trade always pays off for me.

For PHP I installed the extensions the stack needs and nothing else: imagick, xml, mbstring, curl, zip, intl, and opcache. Every extension is code running with privileges; the fewer there are, the less surface.

What a tutorial doesn’t tell you

The technical decision most often skipped by omission is how Nginx talks to PHP. Most tutorials leave PHP-FPM listening on TCP (127.0.0.1:9000) and nobody explains why. I put it on a Unix socket (unix:/var/run/php/php8.3-fpm.sock).

The reason: on a single-machine server, where Nginx and PHP-FPM live together, the Unix socket is faster. They communicate directly through the file system, without going through the TCP/IP stack and its overhead. TCP only makes sense when PHP-FPM runs on a machine separate from Nginx, which isn’t my case. Using TCP on localhost is paying a network toll for a trip that never leaves the house.

The other detail is that a single server block serves two things at once: the plain-PHP portfolio at the root and a WordPress that lives in the /blog/ subdirectory. I don’t need two blocks or two domains; with the right location directives, the same server routes the traffic. Here’s the core of the config, with the paths anonymized:

server {
    listen 443 ssl http2;
    server_name example.com;

    root /var/www/site/public;
    index index.php index.html;

    location / {
        try_files $uri $uri/ /index.php?$args;
    }

    location /blog/ {
        try_files $uri $uri/ /blog/index.php?$args;
    }

    location ~ \.php$ {
        include snippets/fastcgi-php.conf;
        fastcgi_pass unix:/var/run/php/php8.3-fpm.sock;
    }
}

I enabled OPcache from the start. It compiles PHP’s bytecode once and caches it in memory, so it isn’t reparsed on every request. For a site with real traffic it’s one of the cheapest performance wins there is: you change a couple of config lines and that’s it.

Hardening it from day one

Hardening isn’t a final step you do when you “have time.” It’s the first thing, because the moment the server has a public IP it starts getting automated access attempts. The bots don’t wait for you to finish.

I started with the database. mysql_secure_installation isn’t a formality where you hit Enter five times: I removed the anonymous users, blocked remote root login, dropped the test database, and set a serious root password. Each of those steps closes a door that comes open by default.

I handled the firewall with UFW, with a deny-everything policy and only the essentials opened: SSH, HTTP, and HTTPS. Nothing else exposed. If a service doesn’t need to be reachable from outside, it isn’t.

I moved SSH to a non-standard port. And here I’ll be honest about what this is and isn’t: it’s not real security. Moving SSH off 22 won’t stop a determined attacker. What it does is cut the noise: the vast majority of automated scans hit port 22 and move on, so changing the port clears the junk out of my logs and lets me see the attempts that actually matter. SSH’s real hardening is in the keys and in disabling password login, not in the port number.

I solved TLS with Let’s Encrypt. Free certificate, automatic renewal, HTTPS from day one. In 2026 there’s no excuse for serving plain HTTP, and setting this up takes ten minutes.

Users and permissions: the part that actually costs you

This is the part no “install LEMP in five minutes” tutorial tells you about, and it’s the one that took me the most time. I have several projects on the same server and I wanted each one managed separately, so that access to one didn’t grant access to the others.

The setup: one general SSH user to administer the machine, and then a separate FTP user per project. Each one only reaches its own files.

The real problem shows up the moment you mix that with PHP. PHP-FPM runs as www-data, but the files are uploaded by an FTP user who is someone else entirely. If you don’t align groups and permissions, one of two things happens: either PHP can’t read what the FTP user uploaded, or the FTP user can’t write where PHP needs to. The server looks broken and the cause isn’t obvious, because it’s not an Nginx config error or a PHP one: it’s file ownership.

I solved it by adjusting the groups and permissions of each project folder so that the corresponding FTP user and www-data could coexist, each reading and writing what it should, without stepping on each other and without any project having access to the one next door. It sounds simple written out like this; in practice it’s where the afternoon goes if you don’t think it through up front.

What I left production-ready and what I’d do differently

What I have now is a server I can forget about: security updates through the official channel, a certificate that renews itself, a closed firewall, and each project in its own pen. For my personal projects it’s exactly the level I need.

The Unix socket and OPcache I’d do again without thinking. They’re zero-cost, immediate-benefit decisions that a lot of people skip only because the tutorial they followed left them out.

What would I change? FTP access. It works, but FTP is from another era; sftp over the same SSH connection would be cleaner and one less thing to harden separately. It’s not urgent, but it’s the first thing I’d touch on the next pass.

If you take one idea from this, make it this one: installing the stack is the easy, solved part. The real work is in the decisions nobody copies and pastes —the socket, the permissions, what you close and what you leave open. That’s where a server goes from “it starts” to “I’ll put it in production and sleep fine.”