← Back to Blog List

Automating a Secure LEMP Stack: From Zero to HTTPS in Under 2 Minutes

May 9, 2026

6 min read

Setting up a LEMP (Linux, Nginx, MySQL, PHP) stack is a rite of passage for every backend developer and system administrator. However, doing it manually is a recipe for inconsistency. One day you’re on PHP 8.1, the next you’re on 8.5, and suddenly you’re staring at a 502 Bad Gateway with no idea where to start.

In this post, I’ll walk you through my latest project: a Bash-based automation tool that provisions a secure, optimized, and HTTPS-ready LEMP stack in under 2 minutes.


Prerequisites

Before running the script, make sure you have:

  • A fresh Ubuntu 26.04 LTS VM running inside VirtualBox
  • A user account with sudo privileges
  • SSH access or direct terminal access to the VM
  • Internet connection from inside the VM (for package downloads)

VirtualBox Network Configuration: Set the VM’s network adapter to Bridged Adapter so the VM gets its own IP on your local network. This is required for myporto.local to be reachable from your host machine’s browser.

NAT (VirtualBox default) will not work for this setup without additional port forwarding configuration.

Enable OpenSSH during Ubuntu installation: When going through the Ubuntu installer, make sure to check “Install OpenSSH Server” on the software selection screen. If you skipped it, install it manually before running the script:

bash

sudo apt install openssh-server -y

The Goal

The objective was simple: zero-touch provisioning. I wanted a script that I could run on a fresh Ubuntu 26.04 server and have a fully secured web environment ready for a portfolio or a production app, without manually touching a single config file.


Technical Architecture

The script doesn’t just “install packages”, it manages the delicate relationship between the web server and the PHP interpreter.

1. Repository Management Uses ppa:ondrej/php to ensure access to the latest stable PHP 8.5 builds, bypassing the outdated versions bundled with Ubuntu’s default repos.

2. Version-Driven Socket Mapping Instead of hardcoding the PHP-FPM socket path, the script uses a single PHP_V variable to dynamically construct the correct socket path. This means upgrading PHP in the future is a one-line change, update the variable, re-run the script, done.

PHP_V="8.5"
# Results in: /var/run/php/php8.5-fpm.sock
fastcgi_pass unix:/var/run/php/php${PHP_V}-fpm.sock;

3. Security First Automates UFW (Uncomplicated Firewall) setup, specifically whitelisting SSH (Port 22) before activation to prevent remote lockout, then opens ports 80 and 443 for web traffic.

4. Local HTTPS with OpenSSL Generates self-signed SSL certificates using OpenSSL so development always happens over an encrypted channel from day one.

Why OpenSSL and not Let’s Encrypt? Let’s Encrypt requires the domain to be publicly reachable from the internet for its HTTP validation challenge. Since this project runs on a local VirtualBox VM with a .local domain that only exists in /etc/hosts, Let’s Encrypt’s validation would simply fail. OpenSSL self-signed certificates are the correct tool for this context — they enable HTTPS locally without needing a public IP or a real registered domain. Let’s Encrypt will be integrated in the next iteration for public-facing deployments.

For VPS / Public-Facing Deployments: If you’re running this on a VPS with a real domain, swap the OpenSSL block for Certbot and automate the renewal with a cron job:

# Install Certbot
sudo apt install certbot python3-certbot-nginx -y

# Issue certificate for your domain
sudo certbot --nginx -d yourdomain.com

Let’s Encrypt certificates expire every 90 days. Automate renewal by adding this to your crontab:

# Open crontab editor
crontab -e

Then add this line:

0 3 * * * certbot renew --quiet --deploy-hook "systemctl reload nginx"

This runs every day at 3:00 AM, silently renews the certificate if it’s within 30 days of expiry, and reloads Nginx automatically so the new cert takes effect without downtime.


The Challenges (And How I Solved Them)

1. The “Locked Out of My Own Server” Problem

During initial testing, the script enabled the firewall before whitelisting SSH. This effectively locked the door with me still inside, no more SSH access, and I had to rebuild the VM from scratch.

The Fix: I reordered the logic to always run ufw allow ssh first, before ufw --force enable. The management tunnel stays open while the rest of the server gets locked down.

# Always allow SSH before enabling the firewall
sudo ufw allow ssh
sudo ufw allow 'Nginx Full'
sudo ufw --force enable   # Safe to enable now

2. The 403 Forbidden on an Empty Root

Even with a perfectly valid Nginx config, the server returned a 403 Forbidden if the web root directory was empty. The reason: Nginx’s try_files $uri $uri/ = 404 directive has nothing to serve if there’s no index file present.

The Fix: The script now automatically generates a phpinfo() landing page at /var/www/html/info.php immediately after configuration, providing instant visual proof that Nginx, PHP-FPM, and the socket connection are all working correctly. I have also added an index.php file that you can place in the root, for more details you can see the github that I placed at the bottom of the page or follow the Try It Yourself section.

echo "<?php phpinfo(); ?>" | sudo tee /var/www/html/info.php

See It In Action

I recorded the entire provisioning process using Asciinema. You can watch the script handle everything from package updates to SSL certificate generation in real-time.

After the script finishes, the confirmation page looks like this:

blog-image

Try It Yourself

The setup is three commands:

git clone https://github.com/eggimugi/lemp.git
cd lemp
chmod +x setup.sh && ./setup.sh
sudo cp index.php /var/www/html/index.php

Then add this line to your host machine’s /etc/hosts file (replace <VM_IP> with the output of ip a inside your VM):

<VM_IP>   myporto.local

Open https://myporto.local in your browser, bypass the self-signed certificate warning, and you’re in.


Key Takeaways

This project reinforced a core DevOps principle: Infrastructure as Code (IaC). By scripting the entire server setup, I’ve eliminated the “it works on my machine” problem and created a reusable, version-controlled asset I can spin up on any fresh VM in minutes.

More importantly, it forced me to think about the order of operations, something manual setup never really teaches you. The firewall incident, the empty root directory, the socket path, none of these were in any tutorial. They only showed up when things broke, and that’s where the real learning happened.


Check Out the Code

The full source code and README are available on GitHub:

https://github.com/eggimugi/lemp.git

← Back to Blog List