Introduction

This guide will walk you through setting up the
LEMP (Linux, nginx [engine x], MySQL, php) or
LNMP (Linux, nginx, MySQL, php)
web server stack with a modern, sensible configuration.


MySQL

MySQL is the RDBMS (database) part of the stack.

MySQL installation

sudo apt update # Update list of available packages
sudo apt show mysql-server # Inspect the MySQL version available
sudo apt install mysql-server # Install MySQL
sudo systemctl enable mysql # Enable the MySQL service on startup
sudo systemctl start mysql # Start the MySQL service now

MySQL configuration

First, change the authentication method of the MySQL root user to avoid a bug with mysql_secure_installation. This user has full access, so be sure to set a secure password here (unless you're reverting to socket auth later). I recommend generating a password and storing it in a password database.

sudo mysql -uroot
ALTER USER 'root'@'localhost' IDENTIFIED WITH 'mysql_native_password' BY 'password';
(out)Query OK, 0 rows affected (0.00 sec)
\q
(out)Bye

Run mysql_secure_installation to secure the MySQL installation. Make sure to follow through the entire script and answer all its prompts. The script should be plenty verbose not to warrant any further instructions on it.

sudo mysql_secure_installation

mysql_secure_installation only needs to be ran once, so you may now (optionally) revert to unix socket authentication for the MySQL root user.

sudo mysql -uroot -p
ALTER USER 'root'@'localhost' IDENTIFIED WITH 'auth_socket';
(out)Query OK, 0 rows affected (0.00 sec)

You may also want to create users and grant database privileges at this point. To restrict access further than ALL PRIVILEGES, refer to the summary of available privileges and GRANT statement in the MySQL reference manual.

CREATE USER 'user'@'localhost' IDENTIFIED BY 'password';
(out)Query OK, 0 rows affected (0.00 sec)
GRANT ALL PRIVILEGES ON `database_name`.* TO 'user'@'localhost';
(out)Query OK, 0 rows affected (0.00 sec)

After a GRANT statement, FLUSH PRIVILEGES needs to be ran for the changes to take effect.

FLUSH PRIVILEGES;
(out)Query OK, 0 rows affected (0.00 sec)
\q
(out)Bye

php

php is the scripting language used in this stack. It's also one of the most widely used scripting languages for web development.

php installation

Note that we install php-fpm. This is the FastCGI Process Manager release of php which will be integrated with nginx later.

sudo apt update # Update list of available packages
sudo apt show php-fpm # Inspect the php-fpm version available
sudo apt install php-fpm # Install php-fpm
# sudo apt install php-cli # Optionally also install php-cli
sudo systemctl enable -t service php* # Enable the php-fpm service on startup
sudo systemctl start -t service php* # Start the php-fpm service now

php configuration

The default php configuration is usually completely fine, though you should take care to adjust a few settings related to error reporting and logging depending on whether the server is for development or production use.

The following configuration is largely based on the default PHP 8 configuration, with most of the comments stripped and settings reorganized into fewer categories for easier readability.

[PHP]

;
; Production values
;

; Error settings
display_errors = Off
display_startup_errors = Off
error_reporting = E_ALL & ~E_DEPRECATED & ~E_STRICT

;
; Development values
;

; Error settings
;display_errors = On
;display_startup_errors = On
;error_reporting = E_ALL

;
; Shared values
;

; Other settings
engine = On
short_open_tag = Off
precision = 14
implicit_flush = Off
unserialize_callback_func =
serialize_precision = -1
zend.enable_gc = On
ignore_repeated_source = Off
variables_order = "GPCS"
request_order = "GP"
register_argc_argv = Off
auto_globals_jit = On
auto_prepend_file =
auto_append_file =
doc_root =
user_dir =
enable_dl = Off
allow_url_fopen = On
allow_url_include = Off

; Security settings
expose_php = Off
disable_functions = pcntl_alarm,pcntl_fork,pcntl_waitpid,pcntl_wait,pcntl_wifexited,pcntl_wifstopped,pcntl_wifsignaled,pcntl_wifcontinued,pcntl_wexitstatus,pcntl_wtermsig,pcntl_wstopsig,pcntl_signal,pcntl_signal_get_handler,pcntl_signal_dispatch,pcntl_get_last_error,pcntl_strerror,pcntl_sigprocmask,pcntl_sigwaitinfo,pcntl_sigtimedwait,pcntl_exec,pcntl_getpriority,pcntl_setpriority,pcntl_async_signals,
disable_classes =
magic_quotes_gpc = Off
cgi.force_redirect = On

; Output settings
default_mimetype = "text/html"
default_charset = "UTF-8"
output_buffering = 4096
zlib.output_compression = Off

; Upload settings
file_uploads = On
post_max_size = 100M
upload_max_filesize = 100M
max_file_uploads = 20
;upload_tmp_dir = "/tmp/"

; Execution settings
max_execution_time = 30
max_input_time = 60
memory_limit = 256M
default_socket_timeout = 60

; Error settings
log_errors = On
log_errors_max_len = 1024
ignore_repeated_errors = Off
html_errors = On
report_memleaks = On
track_errors = Off

; Extension settings

[CLI Server]
cli_server.color = On

[Date]
date.timezone = Europe/Oslo

[Pdo_mysql]
pdo_mysql.cache_size = 2000
pdo_mysql.default_socket=

[mail function]
; Windows
; http://php.net/smtp and http://php.net/smtp-port
;SMTP = localhost
;smtp_port = 25
;mail.add_x_header = Off
; Unix
; http://php.net/sendmail-path
;sendmail_path =

[ODBC]
odbc.allow_persistent = On
odbc.check_persistent = On
odbc.max_persistent = -1
odbc.max_links = -1
odbc.defaultlrl = 4096
odbc.defaultbinmode = 1

[Interbase]
ibase.allow_persistent = 1
ibase.max_persistent = -1
ibase.max_links = -1
ibase.timestampformat = "%Y-%m-%d %H:%M:%S"
ibase.dateformat = "%Y-%m-%d"
ibase.timeformat = "%H:%M:%S"

[MySQLi]
mysqli.max_persistent = -1
mysqli.allow_persistent = On
mysqli.max_links = -1
mysqli.cache_size = 2000
mysqli.default_port = 3306
mysqli.default_socket =
mysqli.default_host =
mysqli.default_user =
mysqli.default_pw =
mysqli.reconnect = Off

[mysqlnd]
mysqlnd.collect_statistics = On
mysqlnd.collect_memory_statistics = Off

[PostgreSQL]
pgsql.allow_persistent = On
pgsql.auto_reset_persistent = Off
pgsql.max_persistent = -1
pgsql.max_links = -1
pgsql.ignore_notice = 0
pgsql.log_notice = 0

[bcmath]
bcmath.scale = 0

[Session]
session.save_handler = files
session.save_path = "/var/lib/php/session"
session.use_strict_mode = 0
session.use_cookies = 1
session.use_only_cookies = 1
session.name = PHPSESSID
session.auto_start = 0
session.cookie_lifetime = 0
session.cookie_path = /
session.cookie_domain =
session.cookie_httponly =
session.serialize_handler = php
session.gc_probability = 1
session.gc_divisor = 1000
session.gc_maxlifetime = 7200
session.referer_check =
session.cache_limiter = nocache
session.cache_expire = 180
session.use_trans_sid = 0
session.sid_length = 26
session.trans_sid_tags = "a=href,area=href,frame=src,form="
session.sid_bits_per_character = 5

[Assertion]
zend.assertions = -1

[Tidy]
tidy.clean_output = Off

[soap]
soap.wsdl_cache_enabled=1
soap.wsdl_cache_dir="/tmp"
soap.wsdl_cache_ttl=86400
soap.wsdl_cache_limit = 5

[ldap]
ldap.max_links = -1

nginx

nginx is the HTTP server or "web server" in the stack.

Though its configuration language has many pitfalls or easy-to-make mistakes, these are well documented and easy to avoid once you know about them.

nginx installation

It's recommended to use the official nginx linux packages instead of the ones provided with your linux distribution.

Follow nginx instructions to install Linux packages

nginx configuration

The following paragraph of the nginx Beginner's Guide is quite important, as it tells you about a setting absolutely critical to scaling nginx according to the server's resources:

nginx has one master process and several worker processes. The main purpose of the master process is to read and evaluate configuration, and maintain worker processes. Worker processes do actual processing of requests. nginx employs event-based model and OS-dependent mechanisms to efficiently distribute requests among worker processes. The number of worker processes is defined in the configuration file and may be fixed for a given configuration or automatically adjusted to the number of available CPU cores (see worker_processes).

Basically, you should change /etc/nginx/nginx.conf to look more like the following. Keep in mind you may have to change some filepaths, user and/or pid to match the nginx install and system configuration, so only use this as a reference.

user nginx;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;

# You might want to change the following depending on available resources
# auto = all available CPUs/cores
worker_processes auto;
worker_cpu_affinity auto;
events {
  worker_connections 1024;
}

http {
  include mime.types;
  default_type application/octet-stream;

  # The following log format is similar to the default "combined" format, adding:
  # - "$http_host" before "$request"
  # - "$http_x_forwarded_for" after "$http_user_agent"
  log_format  main  '$remote_addr - $remote_user [$time_local] "$http_host" "$request" '
                    '$status $body_bytes_sent "$http_referer" '
                    '"$http_user_agent" "$http_x_forwarded_for"';
  access_log /var/log/nginx/access.log main;

  sendfile on;
  tcp_nopush on;
  tcp_nodelay on;
  server_tokens off;
  absolute_redirect off;
  port_in_redirect off;

  # Uncomment the following if you are running nginx behind a proxy (like haproxy)
  # keepalive_timeout 65;

  upstream php {
      server unix:/var/run/php/php-fpm.sock;
  }

  include conf.d/*.conf;
}

nginx php integration

To make it easy to use php in multiple nginx configurations, create a file containing a location that can be included in any server block to handle php requests. It's worth mentioning that creating an unsafe php configuration is an easy mistake to make. The following is a known good, safe configuration which suits most use cases.

The configuration is partially inspired by the PHP FastCGI Example provided by nginx, but modified to respect error_page and harden against DoS.

Create a folder to contain small reusable nginx configuration snippets. This will be useful in scenarios with multiple similar nginx configuration files.

mkdir /etc/nginx/snippets

Create the file /etc/nginx/snippets/fcgi-php.conf with the following contents.

# Disable caching dynamic content
expires off;

# Bypass the fact that try_files resets $fastcgi_path_info
# See: http://trac.nginx.org/nginx/ticket/321
set $path_info $fastcgi_path_info;
fastcgi_param PATH_INFO $path_info;

# Disable FCGI disk buffering
# This prevents DoS via temp file creation
fastcgi_buffering off;
fastcgi_max_temp_file_size 0;

# Mitigate https://httpoxy.org/ vulnerabilities
fastcgi_param HTTP_PROXY "";

# Pass request to php with nginx default FCGI params
fastcgi_index index.php;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_pass php;

Create the file /etc/nginx/snippets/location-php.conf with the following contents.

location ~ \.php$ {
  # Split path after .php file extension and check that file exists
  # This avoids RCE via uploaded file execution
  fastcgi_split_path_info ^(.+?\.php)(/.*)$;
  try_files $fastcgi_script_name =404;

  include snippets/fcgi-php.conf;
}

Free SSL/TLS certificates, encryption and privacy for everyone

Thanks to ACME, we now have free SSL/TLS certificates available for everyone. It's highly recommended to use SSL/TLS on any public website now, in order to prevent MITM attacks and thwart shady ad-injecting practices, surveillance (and who knows what else) by ISPs and governments.

Pick an ACME service provider

Install an ACME client

Integrate Certbot with nginx

The most maintainable method of integrating Certbot with nginx is probably the Certbot webroot authenticator. There's also an nginx plugin, but it will directly modify your nginx configuration files and you might not want that.

Create a webroot folder to be used by Certbot and nginx.

mkdir /var/www/certbot

Using the nginx snippets folder created earlier, create the file /etc/nginx/snippets/location-certbot.conf with the following contents.

location ^~ /.well-known/acme-challenge/  {
  allow all;
  default_type "text/plain";
  root /var/www/certbot;
}

location = /.well-known/acme-challenge/ {
  return 404;
}

Include the snippet in /etc/nginx/conf.d/default.conf in order to issue certificates for domains you don't have a specific configuration for yet.

server {
  server_name _;
  listen 80 default_server;

  charset utf-8;
  root /var/www/default;
  index index.html;

  include snippets/location-certbot.conf;

  location / {
    try_files $uri $uri/ =404;
  }
}

Reload the nginx configuration to apply any changes you've made.

sudo nginx -s reload

Issue a certificate

Issue a certificate using the webroot authenticator. The arguments used are explained in the Certbot User Guide

Essentially we tell Certbot to use the Let's Encrypt ACME v2 server, a 4096-bit RSA key (the default is a 2048-bit RSA key), reload the nginx configuration (and SSL certificates) when deploying (issuing or renewing) a certificate, use the webroot /var/www/cerbot to authenticate the domain and issue a certificate for the domain example.com.

ℹ️ Replace example.com with a domain pointing to your web server
sudo certbot certonly \
--server https://acme-v02.api.letsencrypt.org/directory \
--key-type rsa \
--rsa-key-size 4096 \
--deploy-hook "nginx -s reload" \
--webroot -w /var/www/certbot \
-d example.com

Use an SSL/TLS certificate in nginx

In order to ensure the security of your SSL/TLS-encrypted connections, you need to tweak the nginx settings to follow modern recommendations for encryption algorithms and ciphers.

Download the RFC 7919-defined standard 4096-bit Diffie-Hellman parameters file ffdhe4096.txt for use with your 4096-bit SSL/TLS certificates.

sudo curl https://ssl-config.mozilla.org/ffdhe4096.txt -o /etc/ssl/ffdhe4096.pem

Create a new nginx configuration snippet /etc/nginx/conf.d/snippets/security-ssl.conf containing a general-purpose strong SSL/TLS configuration

⚠️ You may want to read more about HSTS and tweak the corresponding Strict-Transport-Security header to your needs
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:10m; # about 40000 sessions
ssl_session_tickets off;

ssl_dhparam /etc/ssl/ffdhe4096.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;

# HSTS (31536000 seconds, meaning 1 year)
add_header Strict-Transport-Security "max-age=31536000" always;

# OCSP stapling
ssl_stapling on;
ssl_stapling_verify on;

# replace with the IP address of your DNS resolver
resolver 127.0.0.1;

Finally to put it all together, the following nginx website configuration supports HTTP/2, php, automated certificate renewal using the webroot authenticator, HTTPS redirection, HSTS, OCSP stapling and strong SSL/TLS ciphers/parameters.

ℹ️ Replace example.com with a domain pointing to your web server
server {
  listen 80 default_server;
  listen [::]:80 default_server;
  server_name example.com;

  include snippets/location-certbot.conf;

  location / {
    return 301 https://$host$request_uri;
  }
}

server {
  listen 443 ssl http2;
  listen [::]:443 ssl http2;
  server_name example.com;

  root /var/www/example.com;
  charset utf-8;
  index index.php index.html;

  include snippets/security-ssl.conf;

  ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
  ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
  ssl_trusted_certificate /etc/letsencrypt/live/example.com/chain.pem;

  include snippets/location-php.conf;

  location / {
    try_files $uri $uri/ =404;
  }
}

Create /var/www/example.com/index.php and visit https://example.com to test the configuration

<?php
echo "Hello world!";
✅ That's it, you're done setting up the web server stack and ready to serve up some content

Written by
Alexander Sagen