My Ultimate LEMP Server: A Migration Story

Estimated reading time: 11 minutes

Table of contents

Forcing My Hand

Every so often, a piece of your tech infrastructure forces your hand. For me, it was my trusty TurnKey Linux WordPress container where my personal site was hosted. It served me well for years; it’s quick and easy to setup and perfect when you don’t have the knowledge or time or are just too lazy to setup a LAMP or LEMP server. But with the release of Debian 13 “Trixie,” and the fact that Debian 12 “Bookworm” would be EOL in 9 months (well, at least the Security Support), I hit a crossroads. As any TurnKey Linux user knows, the only officially supported path to a major new version is a full migration of your data, not an in-place upgrade.

So What Am I Going to Do?

This got me thinking. If I had to go through the effort of a migration anyway, why not build something new from the ground up and maybe better?

My recent success building an Alpine Linux jump box was fresh in my mind. The speed, the simplicity, the security – it was a breath of fresh air compared to bulkier systems. Consequently, the decision was made. I would migrate my site not to another TurnKey Linux container, but to a custom-built LEMP server running on Alpine Linux from the ground up.

However, I wasn’t just going to replicate my old setup. I had a specific checklist for this new build:

  • Rock-Solid Security: Key-based SSH, a modern firewall (nftables), and automated brute-force protection.
  • Future-Proof PHP: The ability to run multiple PHP versions side-by-side (let’s go with 8.3 and 8.4) for different projects.
  • Raw Performance: Squeezing every bit of speed out of the system with isolated, per-site Nginx FastCGI caching and the high-performance Mimalloc memory allocator.
  • Easy Management: Per-site configurations, separate logs with rotation, and full IPv6 support.

The Birth of My Ultimate LEMP Server

After a lot of tweaking and testing, I’ve landed on a setup I’m proud of. This isn’t just a server; it’s a finely tuned machine born from necessity. So, here’s my journey, with all the commands I used along the way.

Step 1: Laying the Foundation

First things first, I never work as root. It’s a bad habit that can lead to catastrophic mistakes. For that reason, the first order of business on a fresh Alpine Linux install is to create a new user and give it sudo rights.

Instead of just adding my user to the wheel group, I prefer creating a dedicated sudoers file. It’s a cleaner, more explicit way to manage permissions. As a result, you always know exactly who has root access. But first, a quick system update and upgrade, followed by installing the essential tools.

# First, Update & Upgrade 
apk update && apk upgrade
# Install nano, sudo, shadow, bash, curl and wget
apk add nano sudo shadow bash curl wget 
# Create the new user, let's call it: myuser
NEWUSER='myuser'
adduser -g "${NEWUSER}" $NEWUSER

Grant sudo privileges in a dedicated file

echo "$NEWUSER ALL=(ALL) ALL" > /etc/sudoers.d/$NEWUSER
chmod 0440 /etc/sudoers.d/$NEWUSER

And change the shell to bash

usermod --shell /bin/bash $NEWUSER

With that done, I can move on to the most critical part: locking down SSH on my new LEMP server.

Step 2: Fort Knox SSH Security

Password authentication is a huge attack vector. My philosophy is simple: keys only, no exceptions. This part, therefore, has two phases: creating a unique key on my laptop and then hardening the server to only accept that key (and my other devices’ keys).

A Quick Note on SSH Key Security

While it may seem convenient to use one SSH key everywhere, that’s a security anti-pattern. The best practice is to generate a new, unique SSH key pair for every device you use. If my laptop is ever compromised, for instance, I only need to revoke its specific key, not hunt it down on every server I manage.

Installing and Enabling SSH

In order to login via SSH on my new LEMP server, I need to install the OpenSSH server and make sure it would start automatically on boot.

# Install the package
apk add openssh
# Enable and start the SSH service
rc-update add sshd default
rc-service sshd start

Generating and Deploying the Key

First, I generated a new ed25519 key on my local machine.

# Run this on the local laptop
ssh-keygen -t ed25519 -C "myuser@mylaptop"

Then, I copied it to the new user on my Alpine Linux server. ssh-copy-id is the perfect tool for this.

# First, get the server's IP
ip a

# Then, from my laptop, copy the key
ssh-copy-id myuser@server_ip

Before I went any further, I opened a new terminal window and tested the key login. This step is crucial to make sure everything works because, indeed, there’s nothing worse than locking yourself out of your own server.

# Test the key login from a new terminal
ssh myuser@server_ip

Hardening the SSH Server

Once I confirmed I was in (without a password prompt, if no password was set for the key), I hardened the server’s SSH configuration. This means explicitly disabling root login and turning off password authentication entirely.

Disable root login via SSH

sudo sed -i 's/^#*PermitRootLogin.*/PermitRootLogin no/' /etc/ssh/sshd_config

Disable Password Authentication

sudo sed -i \
's/^#*PasswordAuthentication.*/PasswordAuthentication no/' \
/etc/ssh/sshd_config

And restart the SSH service to apply the changes

sudo rc-service sshd restart

Now, the only way into this secure web server environment is with my unique key. Perfect… Well, if you have a plan B: Either access to the terminal or the abilit to login from another device with an authorized SSH key. Don’t forget plan B 😉

Step 3: Setting Up Variables

Now that I was securely logged in via SSH on my new LEMP server, it was time to set up some variables. This step makes the rest of the process a simple copy-paste job. Furthermore, it helps avoid typos when dealing with domain and database names. And while I type that, I did make a typo with the site user, so I had to set the variables again and back track where it was used and change it manually…. Make sure you double check these variables!

I’ll need to create a file containing the export statements. Let’s call this file lemp_variables or something similar. A text editor like nano or vim is used to create it.

nano ~/lemp_variables

Inside the file, I’ll paste the variable definitions below and change them, and double check them!

# ----- EDIT THESE VARIABLES -----
export SITE1_DOMAIN="site1.com"
export SITE1_USER="site1"
export SITE1_DB_NAME="site1_db"
export SITE1_DB_USER="site1_user"
export SITE1_CACHE_ZONE="${SITE1_USER}_CACHE"
export SITE2_DOMAIN="site2.com"
export SITE2_USER="site2"
export SITE2_DB_NAME="site2_db"
export SITE2_DB_USER="site2_user"
export SITE2_CACHE_ZONE="${SITE2_USER}_CACHE"
# A strong, unique password for the MariaDB root user
export MARIADB_ROOT_PASSWORD='strong_root_password'
# Strong, unique passwords for the site databases
export SITE1_DB_PASSWORD='strong_site1_password'
export SITE2_DB_PASSWORD='strong_site2_password'
# ---------------------------------

Press Ctrl + O and Enter to save, then Ctrl + X to exit. 

Then I make sure the file has the correct permissions. The chmod command is used for this:

chmod 600 ~/lemp_variables

To make the variables available in my current shell session, I will use the source command:

source ~/lemp_variables

Note: As a final, critical security step, I will delete ~/lemp_variables to ensure none of the passwords I set as environment variables are saved to disk when I’m done.

Step 4: Building the Wall with nftables and SSHGuard

Like with my My Alpine Linux Jump Box, I again opted for nftables as a modern replacement for iptables and paired it with SSHGuard to automatically block brute-force attacks on my Alpine Linux web server.

Configuring the Firewall Ruleset

First, I installed the necessary packages for nftables and its OpenRC service management.

sudo apk add nftables nftables-openrc

Next, I created a simple but effective ruleset at /etc/nftables.nft. It drops all incoming traffic by default but allows traffic for established connections and, crucially, new SSH, HTTP, and HTTPS connections. It also defines a set called sshguard_blacklist that SSHGuard will use to add malicious IPs.

sudo tee /etc/nftables.nft > /dev/null <<'EOF'
#!/usr/sbin/nft -f
flush ruleset
table inet filter {
    set sshguard_blacklist_ipv4 {
        type ipv4_addr
        flags timeout
    }
    set sshguard_blacklist_ipv6 {
        type ipv6_addr
        flags timeout
    }
    chain input {
        type filter hook input priority 0;
        policy drop;
        # Standard accept/drop rules
        ct state {established, related} accept
        ct state invalid drop
        iifname "lo" accept
        ip protocol icmp accept
        ip6 nexthdr icmpv6 accept
        # Drop traffic from both IPv4 and IPv6 blacklists
        ip saddr @sshguard_blacklist_ipv4 drop
        ip6 saddr @sshguard_blacklist_ipv6 drop
        # Accept traffic for our services
        tcp dport { 22, 80, 443 } accept
    }
    chain forward {
        type filter hook forward priority 0;
        policy drop;
    }
    chain output {
        type filter hook output priority 0;
        policy accept;
    }
}
EOF

Automating Brute-Force Defense

To complement the firewall, I installed SSHGuard. It’s an incredibly lightweight C-based tool that watches logs and automatically blocks attackers by adding rules to nftables. It’s a true set-and-forget solution for this Alpine LEMP server.

Install it

sudo apk add sshguard sshguard-openrc

Configure SSHGuard to use the nftables backend that works with the set we just created and pointed it to the correct log file for Alpine.

cat <<'EOF' | tee /etc/sshguard.conf
# Backend: Use the nftables backend that works with sets
BACKEND="/usr/libexec/sshg-fw-nft-sets"
# Files to monitor (Alpine default for SSH logs)
FILES="/var/log/auth.log"
EOF

Enabling the Services

With everything configured, the services need to be enabled at boot and I need to start them. It’s important to start nftables first and verify it’s correct before starting the service that depends on it.

# Enable and start the firewall
sudo rc-update add nftables default
sudo rc-service nftables start
# Verify the ruleset is active
sudo nft list ruleset | grep sshguard_blacklist

The verification command should output the line defining the sshguard_blacklist set. With the firewall confirmed to be active and correct, I then enabled and started SSHGuard.

# Enable and start the brute-force protection
sudo rc-update add sshguard default
sudo rc-service sshguard start

Step 5: The LEMP Stack with a Performance Twist

Now for the main event (finaly you probably think by now): the Linux, Nginx, MariaDB and PHP setup. Wait… What? That is LNMP, right? That’s not a LEMP setup?! Well, did you know Nginx is pronounced as “engine-x”? So that makes it LEMP: Linux as the operating system, Nginx (pronounced “engine-x”) as the web server, MySQL (or MariaDB) as the database and PHP.

Installing Core Components

I made sure to grab all the extensions I typically need for WordPress. This includes, for example, gd for image processing and apcu for object caching.

# Install Nginx, MariaDB, and PHP
sudo apk add nginx mariadb mariadb-client
# Install php83
sudo apk add php83 php83-fpm php83-mysqli php83-json php83-curl php83-dom php83-exif php83-fileinfo php83-mbstring php83-openssl php83-xml php83-zip php83-gd php83-pecl-apcu php83-sqlite3 php83-pecl-igbinary php83-pecl-imagick php83-iconv php83-intl php83-phar php83-json
# Install php84
sudo apk add php84 php84-fpm php84-mysqli php84-json php84-curl php84-dom php84-exif php84-fileinfo php84-mbstring php84-openssl php84-xml php84-zip php84-gd php84-pecl-apcu php84-sqlite3 php84-pecl-igbinary php84-pecl-imagick php84-iconv php84-intl php84-phar php84-json
# Initialize and secure MariaDB
sudo mariadb-install-db --user=mysql --datadir=/var/lib/mysql
sudo rc-service mariadb start && sudo rc-update add mariadb
sudo mysql_secure_installation

Boosting Performance with Mimalloc

The real magic on this Alpine LEMP server is in the performance tuning. I decided to use Mimalloc, a high-performance memory allocator from Microsoft. For multi-threaded applications like Nginx and MariaDB, a better allocator can genuinely reduce latency. Noticed that I’m not mentioning PHP? Well, PHP is not designed for multithreading, but I’ll still use Mimalloc for it. The official benchmarks on its GitHub page are impressive. Moreover, I’d seen community tests showing great results when using Mimalloc on Alpine Linux (well to be honest, the performance is bad without Mimalloc… so it’s a must have).

To get it working, I just had to install it and use sed to preload the library in the service scripts.

# Install mimalloc2
sudo apk add mimalloc2

For Nginx, we’ll change /etc/init.d/nginx

sudo sed -i 's|^command=\${command:-/usr/sbin/nginx}|command="/usr/bin/env LD_PRELOAD=/usr/lib/libmimalloc.so.2 \${command:-/usr/sbin/nginx}"|' /etc/init.d/nginx

Before:

command=${command:-/usr/sbin/nginx}

After:

command="/usr/bin/env LD_PRELOAD=/usr/lib/libmimalloc.so.2 ${command:-/usr/sbin/nginx}"

For MariaDB, we’ll change /etc/init.d/mariadb:

sudo sed -i 's|^command="/usr/bin/mariadbd-safe"|command="/usr/bin/env LD_PRELOAD=/usr/lib/libmimalloc.so.2 /usr/bin/mariadbd-safe"|' /etc/init.d/mariadb

Before:

command="/usr/bin/mariadbd-safe"

After:

command="/usr/bin/env LD_PRELOAD=/usr/lib/libmimalloc.so.2 /usr/bin/mariadbd-safe"

For PHP-FPM 8.3, we’ll change /etc/init.d/php-fpm83:

sudo sed -i 's|^command="/usr/sbin/php-fpm83"|command="/usr/bin/env LD_PRELOAD=/usr/lib/libmimalloc.so.2 /usr/sbin/php-fpm83"|' /etc/init.d/php-fpm83

Before:

command="/usr/sbin/php-fpm83"

After:

command="/usr/bin/env LD_PRELOAD=/usr/lib/libmimalloc.so.2 /usr/sbin/php-fpm83"

For PHP-FPM 8.4, we’ll change /etc/init.d/php-fpm84:

sudo sed -i 's|^command="/usr/sbin/php-fpm84"|command="/usr/bin/env LD_PRELOAD=/usr/lib/libmimalloc.so.2 /usr/sbin/php-fpm84"|' /etc/init.d/php-fpm84

Before:

command="/usr/sbin/php-fpm84"

After:

command="/usr/bin/env LD_PRELOAD=/usr/lib/libmimalloc.so.2 /usr/sbin/php-fpm84"

Configuring PHP Pools

I also installed the apcu, igbinary and sqlite3 extensions to handle object caching for WordPress. This simple step takes a load off the database with the SQLite Object Cache plugin. Of course, be sure to test the current PageSpeed, before you install plugins that will “improve” performance, and test it after installation to be sure that it in fact did improve.

To isolate each site, I then created a dedicated PHP-FPM pool and user for each. Here’s how I configured the pools, ensuring the socket permissions were correct for Nginx to connect.

# Create new pool config files from the defaults
sudo cp /etc/php83/php-fpm.d/www.conf /etc/php83/php-fpm.d/${SITE1_USER}.conf
sudo cp /etc/php84/php-fpm.d/www.conf /etc/php84/php-fpm.d/${SITE2_USER}.conf

For PHP 8.3 / Site 1:

sudo sed -i -e "s/\[www\]/\[$SITE1_USER\]/" \
           -e "s/^user = .*/user = $SITE1_USER/" \
           -e "s/^group = .*/group = $SITE1_USER/" \
           -e "s|^listen = .*|listen = /var/run/php-fpm83-${SITE1_USER}.sock|" \
           -e "s/^;listen.owner = .*/listen.owner = $SITE1_USER/" \
           -e "s/^;listen.group = .*/listen.group = $SITE1_USER/" \
           -e "s/^;listen.mode = .*/listen.mode = 0660/" \
           -e "s/^[; ]*request_terminate_timeout = .*/request_terminate_timeout = 30/" \
           /etc/php83/php-fpm.d/${SITE1_USER}.conf

For PHP 8.4 / Site 2:

sudo sed -i -e "s/\[www\]/\[$SITE2_USER\]/" \
           -e "s/^user = .*/user = $SITE2_USER/" \
           -e "s/^group = .*/group = $SITE2_USER/" \
           -e "s|^listen = .*|listen = /var/run/php-fpm84-${SITE2_USER}.sock|" \
           -e "s/^;listen.owner = .*/listen.owner = $SITE2_USER/" \
           -e "s/^;listen.group = .*/listen.group = $SITE2_USER/" \
           -e "s/^;listen.mode = .*/listen.mode = 0660/" \
           -e "s/^[; ]*request_terminate_timeout = .*/request_terminate_timeout = 30/" \
           /etc/php84/php-fpm.d/${SITE2_USER}.conf

Finally, I created the system users and added the nginx user to their groups. This step allows for secure inter-process communication.

sudo adduser -D $SITE1_USER
sudo usermod -a -G $SITE1_USER nginx
sudo adduser -D $SITE2_USER
sudo usermod -a -G $SITE2_USER nginx

Step 6: Tying It All Together with Isolated Nginx Caching and Compression

This is where the setup really shines. I initially used a shared cache for all sites (well initially I didn’t use cache at all to be honest), but for a true multi-tenant setup, that’s not ideal. After all, a traffic spike on one site could wipe out the cache for another. The best practice, therefore, is to give each site its own isolated cache.

Defining Global Cache Settings

First, I’ll modify the main nginx.conf file to prepare it for high-performance caching. The configuration below defines separate cache paths for my sites, sets a global cache key for consistency, and uses a map to efficiently handle WordPress caching rules. This ensures all individual site configurations loaded from the http.d directory will have access to these optimized settings.

To implement this, the entire code block below needs to be copied and pasted into the shell:

sudo tee /tmp/nginx_insert.conf > /dev/null <<EOF
    # Define cache paths for each site.
    fastcgi_cache_path /var/cache/nginx/${SITE1_USER} levels=1:2 keys_zone=${SITE1_CACHE_ZONE}:100m inactive=60m max_size=500m;
    fastcgi_cache_path /var/cache/nginx/${SITE2_USER} levels=1:2 keys_zone=${SITE2_CACHE_ZONE}:100m inactive=60m max_size=500m;
    # Define the global cache key for creating unique cache entries.
    fastcgi_cache_key "\$scheme\$request_method\$host\$request_uri";
    # Map conditions for WordPress-specific caching.
    map \$request_method\$request_uri\$http_cookie \$skip_cache_wp {
        default 0;
        ~^(?!GET|HEAD) 1;
        ~*/wp-admin/ 1;
        ~*/xmlrpc.php 1;
        ~*/wp-.*\.php 1;
        ~*/feed/ 1;
        ~*sitemap(_index)?\.xml 1;
        ~comment_author 1;
        ~wordpress_[a-f0-9]+ 1;
        ~wp-postpass 1;
        ~wordpress_no_cache 1;
        ~wordpress_logged_in 1;
    }
EOF
# --- Execution ---

# Step 1: Insert the contents of our temporary file right after the 'http {' line.
sudo sed -i '/^[[:space:]]*http[[:space:]]*{/r /tmp/nginx_insert.conf' /etc/nginx/nginx.conf

# Step 2: Clean up the temporary file.
sudo rm /tmp/nginx_insert.conf

Per-Site Configuration with Caching

Next I had to create the cache directories and the per-site configuration file. This file contains all the logic to serve from its dedicated cache or bypass it for logged-in users. Ultimately, this makes for a high-performance web stack.

# Create the necessary directories
sudo mkdir -p /var/cache/nginx/${SITE1_USER} && sudo chown nginx:nginx /var/cache/nginx/${SITE1_USER}
sudo mkdir -p /var/cache/nginx/${SITE2_USER} && sudo chown nginx:nginx /var/cache/nginx/${SITE2_USER}

Site 1 Configuration (I added comments here for clarification, I removed them in the Site 2 config):

sudo tee /etc/nginx/http.d/${SITE1_DOMAIN}.conf > /dev/null <<EOF
# This server block is optimized for WordPress and is ready for Certbot.
server {
    listen 80;
    listen [::]:80;
    # Certbot will use these server names to issue the certificate.
    server_name ${SITE1_DOMAIN} www.${SITE1_DOMAIN};
    root /home/${SITE1_USER}/${SITE1_DOMAIN}/html;
    index index.php index.html;
    access_log /var/log/nginx/${SITE1_DOMAIN}/access.log;
    error_log /var/log/nginx/${SITE1_DOMAIN}/error.log;
    # --- Caching Logic ---
    # The \$skip_cache_wp variable comes from the map in the main nginx.conf.
    # This is the modern, efficient way to handle cache exceptions.
    set \$do_not_cache \$skip_cache_wp;
    # Also, skip the cache for any request with a query string (e.g., search results).
    if (\$query_string != "") {
        set \$do_not_cache 1;
    }
    # --- Security Headers & Rules ---
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header Referrer-Policy "no-referrer-when-downgrade" always;
    add_header X-XSS-Protection "1; mode=block" always;
    # Note: Add HSTS header only after you have HTTPS configured and tested.
    # add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
    # Deny access to sensitive files.
    location ~ /\\. { deny all; }
    location = /wp-config.php { deny all; }
    # --- Browser Caching for Static Assets ---
    # Tells browsers to cache images, CSS, and JS for one month.
    location ~* \\.(css|js|jpg|jpeg|gif|png|svg|webp|ico|woff|woff2|ttf|eot)\$ {
        expires 1M;
        add_header Cache-Control "public, no-transform";
        access_log off;
    }
    # --- Main WordPress Location Block for Permalinks ---
    location / {
        try_files \$uri \$uri/ /index.php?\$args;
    }
    # --- PHP Processing & FastCGI Caching ---
    location ~ \\.php\$ {
        include fastcgi_params;
        fastcgi_pass unix:/var/run/php-fpm83-${SITE1_USER}.sock;
        fastcgi_param SCRIPT_FILENAME \$document_root\$fastcgi_script_name;
        # --- FastCGI Timeout and Buffer Settings ---
        # Set timeouts for connecting, sending, and reading from the PHP-FPM backend.
        fastcgi_connect_timeout 60s;
        fastcgi_send_timeout 60s;
        fastcgi_read_timeout 35s; # This is the most important one.
        # Increase buffer sizes to handle larger responses from PHP.
        fastcgi_buffers 16 16k;
        fastcgi_buffer_size 32k;
        # --- Caching Directives ---
        # Using our single, clean variable to control the cache.
        fastcgi_cache ${SITE1_CACHE_ZONE};
        fastcgi_cache_valid 200 60m;
        fastcgi_cache_bypass \$do_not_cache;
        fastcgi_no_cache \$do_not_cache;
        add_header X-FastCGI-Cache \$upstream_cache_status;
    }
}
EOF

Site 2 Configuration (without the comments):

sudo tee /etc/nginx/http.d/${SITE2_DOMAIN}.conf > /dev/null <<EOF
server {
    listen 80;
    listen [::]:80;
    server_name ${SITE2_DOMAIN} www.${SITE2_DOMAIN};
    root /home/${SITE2_USER}/${SITE2_DOMAIN}/html;
    index index.php index.html;
    access_log /var/log/nginx/${SITE2_DOMAIN}/access.log;
    error_log /var/log/nginx/${SITE2_DOMAIN}/error.log;
    set \$do_not_cache \$skip_cache_wp;
    if (\$query_string != "") {
        set \$do_not_cache 1;
    }
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header Referrer-Policy "no-referrer-when-downgrade" always;
    add_header X-XSS-Protection "1; mode=block" always;
    # Note: Add HSTS header only after you have HTTPS configured and tested.
    # add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
    location ~ /\\. { deny all; }
    location = /wp-config.php { deny all; }
    location ~* \\.(css|js|jpg|jpeg|gif|png|svg|webp|ico|woff|woff2|ttf|eot)\$ {
        expires 1M;
        add_header Cache-Control "public, no-transform";
        access_log off;
    }
    location / {
        try_files \$uri \$uri/ /index.php?\$args;
    }
    location ~ \\.php\$ {
        include fastcgi_params;
        fastcgi_pass unix:/var/run/php-fpm84-${SITE2_USER}.sock;
        fastcgi_param SCRIPT_FILENAME \$document_root\$fastcgi_script_name;
        fastcgi_connect_timeout 60s;
        fastcgi_send_timeout 60s;
        fastcgi_read_timeout 35s;
        fastcgi_buffers 16 16k;
        fastcgi_buffer_size 32k;
        fastcgi_cache ${SITE2_CACHE_ZONE};
        fastcgi_cache_valid 200 60m;
        fastcgi_cache_bypass \$do_not_cache;
        fastcgi_no_cache \$do_not_cache;
        add_header X-FastCGI-Cache \$upstream_cache_status;
    }
}
EOF

Creating the log and web directories:

# Create log and web directories
sudo mkdir -p /var/log/nginx/${SITE1_DOMAIN} && sudo chown nginx:nginx /var/log/nginx/${SITE1_DOMAIN}
sudo mkdir -p /home/$SITE1_USER/$SITE1_DOMAIN/html
sudo mkdir -p /var/log/nginx/${SITE2_DOMAIN} && sudo chown nginx:nginx /var/log/nginx/${SITE2_DOMAIN}
sudo mkdir -p /home/$SITE2_USER/$SITE2_DOMAIN/html

Easy Performance Win: Gzip Compression

Now it is time for an easy performance win: Gzip compression. It’s like zipping a folder before sending it; Nginx compresses assets like HTML, CSS, and JS, so they download much faster for visitors. To make this foolproof, I wrote a script to handle the setup safely, its right here on my GitHub. It avoids manual edits by checking the config, creating a backup, and then applying the optimal settings:

curl -o configure_gzip.sh -L \
https://raw.githubusercontent.com/ramonvanraaij/Scripts/main/linux/nginx/configure_gzip.sh && \
chmod +x configure_gzip.sh && \
sudo ./configure_gzip.sh
Configure Gzip on LEMP server

You can run it again to check if the changes were applied.

Gzip configured on LEMP server

Start PHP and restart Nginx

With everything done, the moment is there to have the services started at boot and to (re)start them in the correct order:

sudo rc-service php-fpm83 start && sudo rc-update add php-fpm83
sudo rc-service php-fpm84 start && sudo rc-update add php-fpm84
sudo rc-service nginx restart

Step 7: Database, SSL, and WordPress Setup

Almost there! With the server running, I created the databases and users non-interactively. This approach ensures the variables are handled correctly.

sudo mariadb -u root -p"${MARIADB_ROOT_PASSWORD}" <<EOF
CREATE DATABASE ${SITE1_DB_NAME};
CREATE USER '${SITE1_DB_USER}'@'localhost' IDENTIFIED BY '${SITE1_DB_PASSWORD}';
GRANT ALL PRIVILEGES ON ${SITE1_DB_NAME}.* TO '${SITE1_DB_USER}'@'localhost';
CREATE DATABASE ${SITE2_DB_NAME};
CREATE USER '${SITE2_DB_USER}'@'localhost' IDENTIFIED BY '${SITE2_DB_PASSWORD}';
GRANT ALL PRIVILEGES ON ${SITE2_DB_NAME}.* TO '${SITE2_DB_USER}'@'localhost';
FLUSH PRIVILEGES;
EOF

I used Certbot to grab SSL certificates. It automatically detected my IPv6 setup and configured Nginx for HTTPS (if I didn’t forget to update/create the DNS records…).

sudo apk add certbot certbot-nginx
sudo certbot --nginx -d $SITE1_DOMAIN -d www.$SITE1_DOMAIN
sudo certbot --nginx -d $SITE2_DOMAIN -d www.$SITE2_DOMAIN

For convenience and to follow a common hosting convention, I added a public_html symlink in each user’s home directory that points to their web root.

sudo ln -s /home/$SITE1_USER/$SITE1_DOMAIN/html /home/$SITE1_USER/public_html
sudo ln -s /home/$SITE2_USER/$SITE2_DOMAIN/html /home/$SITE2_USER/public_html

Setup WordPress!

At this point in the process, I migrated my own website’s data over to the new LEMP server. For anyone following along to set up a brand new WordPress site, there is a bit more to do, but, almost done! Promised!
The permission model is crucial here. To clarify, each site’s files are owned by a dedicated user, and the nginx user is added as a member of that site’s group. This gives Nginx read access while keeping sites securely isolated.

# Download and set up WordPress for Site 1
sudo wget https://wordpress.org/latest.tar.gz --output-document=/home/$SITE1_USER/$SITE1_DOMAIN/html/latest.tar.gz
sudo tar -xzf /home/$SITE1_USER/$SITE1_DOMAIN/html/latest.tar.gz
sudo mv wordpress/* /home/$SITE1_USER/$SITE1_DOMAIN/html/
sudo rm -rf /home/$SITE1_USER/$SITE1_DOMAIN/html/wordpress /home/$SITE1_USER/$SITE1_DOMAIN/html/latest.tar.gz
# Set secure permissions
sudo chown -R $SITE1_USER:$SITE1_USER /home/$SITE1_USER/$SITE1_DOMAIN
sudo find /home/$SITE1_USER/$SITE1_DOMAIN -type d -exec chmod 750 {} \;
sudo find /home/$SITE1_USER/$SITE1_DOMAIN -type f -exec chmod 640 {} \;

Visit the site and finish the WordPress installation
Now, visit the site at https://site1.com and after running through the famous WordPress less than 5-minute install, the site is live on its new, lightning-fast home.

Step 8: Final Touches

To keep my log files in check, I set up a simple logrotate configuration for my LEMP server.

sudo tee /etc/logrotate.d/nginx > /dev/null <<'EOF'
/var/log/nginx/*/*.log {
    daily
    missingok
    rotate 14
    compress
    delaycompress
    notifempty
    create 0640 nginx nginx
    sharedscripts
    postrotate
        if [ -f /run/nginx/nginx.pid ]; then
            kill -USR1 `cat /run/nginx/nginx.pid`
        fi
    endscript
}
EOF

As a final, critical security step, I removed ~/lemp_variables to ensure none of the passwords I set as environment variables were saved to disk, before I do that, I’ve saved all relevant usernames and passwords to my passwordmanager

rm -f ~/lemp_variables

For backups, I’m using this script, for now…
Update: I’ve a new backup strategy!

Final Thoughts: From Migration to Masterpiece

This project was incredibly satisfying. Being forced to migrate turned into a fantastic opportunity to build something from the ground up that was perfectly tailored to my needs. The end result is a LEMP server that is not only lightweight and resource-efficient but also incredibly secure and performant. The combination of nftables, key-only SSH, Mimalloc, and isolated Nginx caching creates a setup that feels both professional and powerful. It’s the perfect foundation for my site, and I can rest easy knowing it’s built on a solid, secure base.


Buy me a coffee 🙂
If you found this post helpful, informative, or if it saved or made you some money, consider buying me a coffee. Your support means a lot and motivates me to keep writing.
You can do so via bunq.me (bunq, iDeal, Bankcontact and Credit- or Debit cards) or PayPal (PayPal and Credit- or Debit cards). Thank you!

Disclaimer
The blog posts, guides, and scripts provided on this website are for informational and educational purposes only. They are provided “as is” and without any warranty of any kind, either express or implied, including, but not limited to, the implied warranties of merchantability, fitness for a particular purpose, or non-infringement.
By using the information or scripts from this blog, you agree that I am not liable for any direct, indirect, incidental, consequential, or any other damages or losses arising from the use of or inability to use the information, scripts, or instructions contained herein. You assume full responsibility for any and all risks associated with the use of this content.
The blog posts, guides, and pages may contain referral/affiliate links. If you make a purchase through these links, I may receive a commission at no additional cost to you.

 
Posted in Alpine Linux, Home Lab, Linux, Linux Tutorials, Network Security, System Administration, Web Hosting TutorialsTags:
Write a comment