Home · Guides / 5 / Secure / 5.2

Guide 5.2

Harden a LEMP stack

LEMP is Linux, NGINX, MySQL (or MariaDB), and PHP. Hardening is a degree of resistance, not a finished state: keep packages current, shrink what is listening, and stop the web stack from guessing filenames or exposing version banners.

Credit This page is an original Tor Onion write-up, based on How to secure LEMP stack by Jeff Wilson on RoseHosting (March 2017, updated June 2022). Their examples use CentOS 7 and a public clearnet vhost. The steps below are restated for Ubuntu and a .onion site that already listens on 127.0.0.1.

Linux host

  1. Install updates and, on a long-lived host, unattended security upgrades.

    sudo apt update
    sudo apt upgrade
    sudo apt install unattended-upgrades
    sudo dpkg-reconfigure --priority=low unattended-upgrades
  2. Do not SSH in as root. Use a sudo user and key authentication. Keep a second session open while you edit /etc/ssh/sshd_config:

    PermitRootLogin no
    PasswordAuthentication no
    sudo systemctl reload ssh
  3. Firewall policy: deny by default, then allow only what you need. For an onion-only web server that is the SSH port (and nothing on 80/443 from the WAN, because NGINX should already bind localhost).

    sudo ufw default deny incoming
    sudo ufw default allow outgoing
    sudo ufw allow OpenSSH
    sudo ufw enable
    sudo ss -nltp

    Stop and disable daemons you do not use (mail, FTP, extra databases). RoseHosting’s original article stresses the same idea with systemctl disable.

NGINX

  1. Run the worker as www-data (Ubuntu default), not root. In /etc/nginx/nginx.conf:

    user www-data;
    server_tokens off;
  2. Keep the site on localhost, as in guide 4. Do not add a public listen 80 “for convenience”.

  3. Onion connections are already encrypted inside Tor. A clearnet TLS redirect (the usual return 301 https://… pair in the RoseHosting article) only matters if you also publish a public hostname. Skip it for a pure .onion vhost.

  4. Do not send every *.php path to PHP-FPM blindly. Require the file to exist, and refuse scripts under upload directories:

    location ~ \.php$ {
        try_files $uri =404;
        include snippets/fastcgi-php.conf;
        fastcgi_pass unix:/run/php/php7.1-fpm.sock;
    }
    
    location /uploads {
        location ~ \.php$ { return 403; }
    }

    Point fastcgi_pass at the PHP version you actually installed.

MySQL

This repeats guide 4.2 because it is the most important database control: nothing but localhost should reach the engine, and each site should have its own user, not root.

bind-address = 127.0.0.1
sudo mysql_secure_installation

PHP

  1. Set cgi.fix_pathinfo=0 in the FPM php.ini (already covered in guide 4.1) so PHP does not execute a neighbour file when the requested path is missing.

  2. Hide the PHP banner and cap resource use. In the FPM ini:

    expose_php = Off
    max_execution_time = 30
    max_input_time = 30
    memory_limit = 128M
    post_max_size = 8M

    If the application never accepts uploads, set file_uploads = Off. If it does, keep upload_max_filesize only as large as the app needs.

  3. Disable unused extensions (php -m) rather than leaving every module loaded. Restart FPM after ini changes:

    sudo service php7.1-fpm restart
What this does not cover Application bugs (SQL injection, XSS, CSRF) live in your PHP code or CMS. Server hardening does not replace updates for WordPress, plugins, or your own queries. RoseHosting makes the same point at the end of their article.