Install and Harden a Debian LAMP Stack on Amazon Lightsail
Building a public-facing web server from a bare operating system image is one of the most effective ways to gain direct control over the hosting environment. Pre-packaged images and managed hosting providers are convenient, but that convenience comes at the cost of control: the operator may be locked into specific software versions, patching schedules set by someone else, and configuration choices that cannot be changed.
Beyond the basic installation, this article covers Apache module configuration choices, PHP-FPM and why it is preferable to mod_php, MariaDB security hardening, HSTS, HTTP/2, SSL/TLS via Let’s Encrypt, and virtual host configuration that enforces HTTPS and www canonicalization. By the end, the server is functional and defensible.
Why Build From a Bare OS Image Instead of a Pre-Built Stack
Pre-packaged server images bundle the software and may constrain how individual components can be upgraded. Depending on the image and its maintenance model, upgrading PHP may require migrating the application to a replacement instance rather than upgrading PHP independently.
Starting from a plain Debian image avoids this constraint. Each component is managed independently through APT, which means PHP, Apache, and MariaDB can each be upgraded on their own schedule without touching the rest of the stack.
Scope
This article covers building a LAMP stack on Debian 13 (Trixie) on an Amazon Lightsail instance, starting from a plain Debian image. It includes:
- Instance creation and static IP assignment.
- IPv4 firewall hardening and IPv6 evaluation.
- Apache installation, module selection rationale, and security configuration.
- PHP installation with PHP-FPM.
- PHP production and security settings.
- MariaDB installation, security hardening, and least-privilege user setup.
- HTTP/2 setup.
- SSL/TLS certificate provisioning with Certbot and Let’s Encrypt.
- Virtual host configuration for HTTP to HTTPS redirection and non-www to
wwwredirection. DocumentRootownership and permissions.- Default virtual host denial for HTTP and HTTPS requests by IP address.
- Apache-level abuse protection with Fail2ban.
- Baseline security headers and a staged HSTS rollout.
- XML-RPC restriction at the virtual host level.
- Rate limiting with
mod_evasive. - Automatic security updates with
unattended-upgrades. - Backup, log rotation, and ongoing maintenance guidance.
Amazon Lightsail is used here, but the Linux configuration steps apply to any Debian 13 server instance from any provider.
Create and Configure the Lightsail Instance
This article assumes access to an AWS account and the AWS Management Console.
Steps 1 through 4 cover Lightsail-specific setup: creating the instance, assigning a static IP, restricting SSH access, and evaluating IPv6. If using a different hosting provider, complete the equivalent steps in that provider’s control panel before continuing. From the Check Baseline Disk Usage section onward, every command runs identically on any Debian 13 server regardless of provider.
Step 1: Create a Debian Instance
Log in to the AWS Management Console and search for Lightsail. Select it from the results.
On the Lightsail home screen, click Create instance.
Review the default Instance location and change it as appropriate.
Under Pick your instance image, select:
- Platform:
Linux operating system. - Blueprint:
Debian 13.

Under Choose your instance plan, select the plan type, network type, and size appropriate for the workload. For a low-traffic website or a staging environment, the entry-level plan is sufficient to validate the full configuration (for example, General purpose and Dual-stack).

In the Configure your instance section, enter a unique name for this instance. Leave the Tagging options section unchanged unless tagging is required.

Click Create instance. The Instance state will show a status of Starting for approximately one minute before transitioning to Running.

Step 2: Attach a Static IP
By default, Lightsail instances are assigned a dynamic public IP address that changes each time the instance is stopped and restarted. A static IP is required before pointing DNS records at the server.
Click the instance name to open its management screen. Click the Networking tab.
In the Public IPv4 address section, click Attach static IP.
Provide a unique name for the static IP resource, then click Create and attach. Click Continue when the confirmation message appears.

The static IP address is now displayed in the IP addresses section. This is the address used in the domain’s DNS A record.
Step 3: Configure the Firewall
In this step, restrict SSH access to known IP addresses and confirm that the HTTP and HTTPS ports required for web traffic and SSL certificate provisioning are open.
Unrestricted SSH access on port 22 means every IP address on the internet can attempt to authenticate against the instance. Restricting access to known IP addresses significantly reduces that attack surface.
In the Firewall rules section, edit the SSH rule. Under Lightsail browser SSH client, confirm Enable IPv4 and Enable IPv6 are enabled to retain the ability to connect through the Lightsail console.
Under Source IP addresses, change the presets from Anywhere IPv4 and Anywhere IPv6 to Custom. Add one or more IP addresses or CIDR ranges (blocks of IP addresses expressed as a single range, such as 203.0.113.0/24) that are permitted to connect via SSH. If the connecting IP address changes regularly, consider using a VPN with a fixed egress IP, or maintaining the Lightsail browser SSH option as a fallback.
Click Update rule.

New Lightsail instances may include rules for HTTP (port 80) and HTTPS (port 443) by default. Confirm both are present in the firewall rule list with Allowed inbound traffic set to Any IPv4 or IPv6 address. Port 80 is required for the Let’s Encrypt HTTP challenge during certificate provisioning. Port 443 serves all HTTPS traffic. If either rule is missing, add it before continuing.

NOTE: This article relies on the Lightsail network firewall and does not install UFW. Running both is possible but increases administrative overhead without significant benefit for most Lightsail deployments.
Optional: Disable SSH Password Authentication
After verifying that SSH key access works, disable password authentication to prevent brute-force attempts against the SSH service. Open the SSH daemon configuration file:
sudo nano /etc/ssh/sshd_config
Locate and set the following directives:
PasswordAuthentication no
PermitRootLogin no
Test the SSH configuration before restarting the service:
sudo sshd -t
sudo systemctl restart ssh
On cloud images, files in /etc/ssh/sshd_config.d/ can override the main configuration file. Confirm the effective values:
sudo sshd -T | grep -E 'passwordauthentication|permitrootlogin'
Both should report no. If not, edit the file in /etc/ssh/sshd_config.d/ that sets the value.
Step 4: Evaluate IPv6 Requirements
Many modern hosting environments support IPv6 by default, and there is nothing inherently insecure about leaving it enabled. The key consideration is whether IPv6 connectivity will be managed and monitored alongside IPv4.
Disabling IPv6 reduces the network surface that must be maintained if it is not needed. If IPv6 traffic is expected, leave it enabled and ensure firewall rules, DNS records, and monitoring cover both protocols.
In the Public IPv6 address section, either leave IPv6 enabled or disable it based on operational requirements.
If IPv6 stays enabled, create an AAAA record only when Apache is reachable over IPv6. Let’s Encrypt validates over IPv6 when an AAAA record exists, so a record that reaches a different server can cause certificate issuance to fail.
Connect to the Instance
At this point, the instance is running with a static IP and a restricted SSH firewall rule.
From the Lightsail home screen, click the instance name and then click the Connect tab.
NOTE: Two connection methods are available. The browser-based SSH client is sufficient for the steps in this article. For ongoing administration, a dedicated SSH client such as PuTTY (Windows) or a terminal with an SSH key configured is more capable.
Click Connect using SSH to open a browser-based terminal session.

If using a different hosting provider, connect using that provider’s browser-based console or an SSH client pointed at the server’s public IP address. The commands from this point forward are identical regardless of connection method.
Check Baseline Disk Usage
Before installing anything, record the current disk usage. This provides a baseline for tracking how much space the LAMP stack consumes and helps identify unexpected disk growth later.
df -h
The output shows used and available space on each mounted filesystem. Note the values for / before proceeding.
Update Installed Packages
Update the local APT package index and upgrade all installed packages before adding anything new. Running upgrades on a fresh instance ensures new software is not installed on top of an unpatched OS.
sudo apt update && sudo apt upgrade -y
Review the output for any notices about a required reboot. If prompted, reboot before continuing:
sudo reboot
Reconnect via SSH after the instance restarts.
Install Apache on Debian
Installation
sudo apt install -y apache2
Apache starts automatically after installation. Verify that it is running:
sudo systemctl status apache2
NOTE: Press
qto return to the prompt.
Configure Apache Modules for HTTP/2 and PHP-FPM
The default Apache installation enables a set of modules, but it does not necessarily enable the ones required, and it may include some that are not. Every enabled module that is not required expands the attack surface of the server.
List all currently loaded modules:
sudo apache2ctl -M
NOTE:
apache2ctlcommands may printAH00558: apache2: Could not reliably determine the server's fully qualified domain nameabove their normal output. The warning is harmless. The globalServerNameset incustom.conflater in this article resolves it.
Review the output against the modules below. The following changes are recommended for a secure, performant server that will run PHP applications.
- Disable
mpm_prefork: The prefork Multi-Processing Module (MPM) handles each connection in a separate process. It is incompatible with HTTP/2 and is less efficient than the event MPM for most workloads. - Enable
mpm_event: The event MPM uses a thread-based model that handles concurrent connections more efficiently. HTTP/2 requires a threaded MPM, and event is the Debian default. - Enable
expires: GeneratesExpiresandCache-ControlHTTP headers, which control browser and proxy caching behavior. Configured in the companion Harden WordPress Security Using Custom .htaccess Files article. - Enable
headers: Allows modification of HTTP request and response headers. Required for setting security headers such as HSTS. - Enable
http2: Enables the HTTP/2 protocol, which provides multiplexing, header compression, and improved performance over HTTPS compared to HTTP/1.1. Requires a threaded MPM such asmpm_event. - Enable
proxyandproxy_fcgi: These modules enable the FastCGI reverse proxy, which is required to pass PHP requests to PHP-FPM. - Enable
rewrite: Enables rule-based URL rewriting. Required by WordPress and many other PHP applications running on Apache. - Enable
ssl: Adds support for TLS (HTTPS). Required for HTTPS virtual hosts.
NOTE: A fresh Debian installation already uses
mpm_event, so the first two commands below may report that no change was needed. Running them confirms the intended state.
Execute the following commands:
sudo a2dismod mpm_prefork
sudo a2enmod mpm_event
sudo a2enmod expires
sudo a2enmod headers
sudo a2enmod http2
sudo a2enmod proxy
sudo a2enmod proxy_fcgi
sudo a2enmod rewrite
sudo a2enmod ssl
Modules that Debian enables by default but this stack does not use can be disabled. status and autoindex are common candidates. Confirm each against the apache2ctl -M output first.
sudo a2dismod status autoindex
Restart Apache to activate the module changes:
sudo apache2ctl configtest
sudo systemctl restart apache2
Install PHP With PHP-FPM
Why PHP 8.4
Debian 13 ships PHP 8.4 in its default repositories. PHP 8.4 receives active support through December 31, 2026 and security-only support through December 31, 2028. A new deployment should consider PHP 8.5 (released November 2025) if the applications have been validated against it. The process for moving to a newer PHP release on this same stack is covered in Upgrade PHP on a Debian LAMP Stack.
Why PHP-FPM Instead of mod_php
PHP-FPM (FastCGI Process Manager) runs as a separate service from Apache. This architecture offers several advantages over the traditional mod_php approach where PHP runs inside the Apache process:
- PHP worker processes are isolated from Apache, so a PHP crash does not take down the web server.
- PHP-FPM pools can be configured independently per virtual host with their own user, process count, and resource limits.
- Memory consumed by PHP is not held inside Apache worker processes, which reduces overall memory pressure.
- PHP-FPM allows Apache to use a threaded MPM such as
mpm_event, which HTTP/2 requires, whereas traditionalmod_phpconfigurations depend on the prefork MPM.
Install PHP 8.4 and Extensions
Install PHP with PHP-FPM and the extensions most commonly required by PHP applications including WordPress:
sudo apt install -y php8.4 php8.4-{common,fpm,mysql,curl,igbinary,imagick,intl,mbstring,xml,zip}
NOTE: Many PHP install commands found online append
libapache2-mod-php8.4to this command. That package installsmod_php, the traditional method of running PHP inside Apache processes. Since this article uses PHP-FPM exclusively,libapache2-mod-php8.4is not installed. Some guides install it and then immediately disable it witha2dismod, which achieves the same result but installs an unnecessary package.
The packages in this command serve the following purposes:
fpm: FastCGI Process Manager service.mysql: PDO and MySQLi database drivers.curl: HTTP client library used by many plugins and APIs.igbinary: Compact binary serialization format, often used with caching systems.imagick: ImageMagick bindings for image processing.intl: Internationalization support.mbstring: Multibyte string handling, required by many frameworks.xml: XML parsing, required by WordPress and most CMS platforms.zip: ZIP archive support, required for plugin and theme installation in WordPress.
The php8.4-imagick package pulls in the required ImageMagick shared libraries as dependencies automatically. Verify that PHP loaded the extension:
php -m | grep -i imagick
The output should be imagick. The magick command-line tool belongs to a separate package and is not required by the PHP extension, so it is not installed in this article.
Configure PHP-FPM for Production
The default PHP-FPM configuration is conservative in ways that cause common operational problems, particularly for WordPress. The PHP-FPM configuration file for PHP 8.4 is located at /etc/php/8.4/fpm/php.ini. Open it:
sudo nano /etc/php/8.4/fpm/php.ini
The file is several hundred lines long. In nano, use Ctrl+W (or Ctrl+F in the browser SSH client) to search for each setting by name rather than scrolling manually. Alternatively, use sed to update each value directly from the command line without opening the file:
sudo sed -i 's/^memory_limit = .*/memory_limit = 256M/' /etc/php/8.4/fpm/php.ini
sudo sed -i 's/^upload_max_filesize = .*/upload_max_filesize = 64M/' /etc/php/8.4/fpm/php.ini
sudo sed -i 's/^post_max_size = .*/post_max_size = 72M/' /etc/php/8.4/fpm/php.ini
sudo sed -i 's/^max_execution_time = .*/max_execution_time = 120/' /etc/php/8.4/fpm/php.ini
Review and adjust the following four settings, which are the most common sources of issues after a fresh installation:
| Setting | Default | Recommended |
|---|---|---|
memory_limit |
128M |
256M |
upload_max_filesize |
2M |
64M |
post_max_size |
8M |
72M |
max_execution_time |
30 |
120 |
memory_limitcontrols how much memory a single PHP process can consume. WordPress with active plugins frequently exceeds128M.upload_max_filesizeandpost_max_sizecontrol the maximum size of uploaded files and POST requests. The defaults may be too restrictive for applications that accept larger uploads.post_max_sizemust be greater thanupload_max_filesize.max_execution_timesets the maximum number of seconds a PHP script can run before it is terminated;30seconds is often too short for plugin installations or import operations.
One security-oriented setting is worth confirming while the file is open:
grep -n '^expose_php' /etc/php/8.4/fpm/php.ini
Debian ships php.ini with expose_php = Off, which prevents PHP from advertising its version in the X-Powered-By response header. If the output shows On, change it with:
sudo sed -i 's/^expose_php = .*/expose_php = Off/' /etc/php/8.4/fpm/php.ini
After saving changes to php.ini, restart PHP-FPM for the new values to take effect:
sudo systemctl restart php8.4-fpm
Optional: Restrict Shell Execution Functions
disable_functions blocks PHP functions that execute OS-level shell commands. When a vulnerable application allows arbitrary code execution, these functions are what an attacker uses to run commands on the server. Restricting them can limit the impact of a compromise.
NOTE: This setting is not universally safe to apply without testing. Backup tools, deployment utilities, image-processing integrations, PDF generators, and some enterprise plugins legitimately use one or more of these functions. Applying
disable_functionswithout verifying compatibility first can break application functionality in ways that are not immediately obvious.If this setting is applied, test the full plugin stack thoroughly before deploying to a live website. If something breaks, check the PHP error log to identify which function was called, then remove only that specific function from the list rather than disabling the directive entirely.
sudo sed -i 's/^disable_functions =.*/disable_functions = exec,passthru,shell_exec,system,proc_open,popen/' /etc/php/8.4/fpm/php.ini
Verify the change:
grep -n '^disable_functions' /etc/php/8.4/fpm/php.ini
After any changes to php.ini, restart PHP-FPM:
sudo systemctl restart php8.4-fpm
Enable PHP-FPM With Apache
Enable the PHP 8.4 FPM configuration for Apache. The setenvif module is already enabled on a default installation, so that command may report no change:
sudo a2enmod setenvif
sudo a2enconf php8.4-fpm
Enable the PHP-FPM service so it starts automatically at boot:
sudo systemctl enable php8.4-fpm
Restart both services to apply the configuration. Restart PHP-FPM first so the socket is ready before Apache attempts to connect to it:
sudo systemctl restart php8.4-fpm
sudo apache2ctl configtest
sudo systemctl restart apache2
Verify that PHP-FPM is running:
sudo systemctl status php8.4-fpm
Also confirm the PHP-FPM socket file exists at the path Apache expects:
ls /run/php/php8.4-fpm.sock
NOTE: If this command returns
No such file or directory, PHP-FPM is not running or failed to start. Runsudo systemctl status php8.4-fpmto see the reason. Apache returns a 503 error when the socket is missing. The Apache error log will showAH02454: FCGI: attempt to connect to Unix domain socket /run/php/php8.4-fpm.sock failedwhen this happens.
Install and Secure MariaDB
MariaDB is an open-source fork of MySQL maintained by the original MySQL developers after MySQL was acquired by Oracle. It is largely compatible with MySQL, and PHP connects to it through the same pdo_mysql and mysqli extensions.
Update packages and install MariaDB:
sudo apt update && sudo apt upgrade -y
sudo apt install -y mariadb-server
Secure the MariaDB Installation
MariaDB ships with several default settings that are appropriate for initial installation but should be changed before the server is accessible to applications. The mariadb-secure-installation script walks through each of them. The script creates temporary files in the current working directory. Run it from a writable directory to avoid permission errors:
cd /tmp && sudo mariadb-secure-installation
When prompted, use the following responses:
| Prompt | Recommended Response |
|---|---|
| Enter current password for root | Press Enter (no password set yet) |
Switch to unix_socket authentication |
n (the script reports that the root account is already protected on Debian) |
| Change the root password | n (not needed with unix_socket authentication) |
| Remove anonymous users | Y |
| Disallow root login remotely | Y |
| Remove test database and access to it | Y |
| Reload privilege tables now | Y |
Depending on the MariaDB version and package configuration, the root account may continue using unix_socket authentication exclusively. This is normal on Debian and does not reduce security.
Restart MariaDB to ensure all configuration changes are active:
sudo systemctl restart mariadb
NOTE: On Debian 13 the service is named
mariadb, notmysql. When older guides or scripts referencesudo systemctl restart mysql, substitutemariadbinstead.
Confirm that MariaDB listens only on the loopback interface:
sudo ss -tlnp | grep 3306
The output should show 127.0.0.1:3306.
Create an Application Database and User
Do not use the MariaDB root account for application connections. Create a dedicated database and user for each hosted application.
Access the MariaDB shell using the following command:
sudo mariadb
Alternatively, use sudo mariadb -u root -p if password authentication has been configured for the root account.
Inside the MariaDB shell:
CREATE DATABASE example_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'example_user'@'localhost' IDENTIFIED BY 'strong_password_here';
GRANT ALL PRIVILEGES ON example_db.* TO 'example_user'@'localhost';
EXIT;
GRANT ALL PRIVILEGES on a single database is appropriate for initial setup and for running migrations or schema changes. For tighter production hardening, consider splitting this into two users. A runtime user limited to SELECT, INSERT, UPDATE, DELETE suits applications that never alter their schema. WordPress core and many plugins create and alter tables during installation and updates, so a WordPress user also needs CREATE, ALTER, DROP, and INDEX.
The utf8mb4 character set with the utf8mb4_unicode_ci collation is appropriate for modern applications. The older utf8 in MySQL/MariaDB is a misnomer that only supports three-byte Unicode characters; utf8mb4 is the full four-byte implementation that supports emoji and supplemental characters.
Harden Apache Security and Enable HTTP/2
Suppress Server Version Information
By default, Apache includes its version number and OS details in HTTP response headers and in server-generated error pages. This information does not need to be public and can help an attacker identify which vulnerabilities apply to the server.
Create a new configuration file:
sudo nano /etc/apache2/conf-available/custom.conf
Add the following content:
# Set a global server name to suppress the AH00558 startup warning
ServerName localhost
# Suppress version and OS information from response headers
ServerTokens Prod
ServerSignature Off
TraceEnable Off
# Enable HTTP/2 over TLS (h2) with HTTP/1.1 fallback
<IfModule mod_http2.c>
Protocols h2 http/1.1
</IfModule>
ServerName localhost sets the server-wide name only. It does not affect the named virtual hosts.
ServerTokens Prod limits the Server response header to just Apache, removing the version number. ServerSignature Off removes the server footer from error pages. These directives operate at the server configuration level and apply globally across all virtual hosts.
TraceEnable Off disables the HTTP TRACE method, which is used for diagnostic purposes and enables cross-site tracing (XST) attacks in some configurations. Disabling it is standard practice for any production server.
NOTE: Many guides include
h2cin theProtocolsdirective alongsideh2.h2cis HTTP/2 over unencrypted TCP and has no practical use on a public HTTPS server. It is omitted here intentionally.
Update the security.conf File
Open the existing security configuration file:
sudo nano /etc/apache2/conf-available/security.conf
Comment out the existing ServerTokens and ServerSignature directives so they do not conflict with the values set in custom.conf.
# ServerTokens Minimal
# ServerTokens OS
# ServerTokens Full
# ServerSignature Off
# ServerSignature On
Deny Access to the Default Virtual Host
Apache’s default virtual host responds to all HTTP requests on port 80 that do not match a named virtual host. Accessing the server’s IP address directly in a browser will serve the default Apache welcome page, which discloses that Apache is installed on the server.
Edit the default host configuration to deny all access:
sudo nano /etc/apache2/sites-available/000-default.conf
Replace the file contents with:
<VirtualHost _default_:80>
DocumentRoot /var/www/html
ErrorLog ${APACHE_LOG_DIR}/error.log
CustomLog ${APACHE_LOG_DIR}/access.log combined
<Directory /var/www/html>
Options None
AllowOverride None
</Directory>
<Location />
Require all denied
</Location>
</VirtualHost>
The virtual host above handles port 80 only. HTTPS requests to the server’s IP address would reach the first :443 virtual host, which is the real site, with a certificate name mismatch. Add a catch-all that sorts before the site’s SSL configuration:
sudo nano /etc/apache2/sites-available/000-default-ssl.conf
<IfModule mod_ssl.c>
<VirtualHost _default_:443>
SSLEngine on
SSLCertificateFile /etc/ssl/certs/ssl-cert-snakeoil.pem
SSLCertificateKeyFile /etc/ssl/private/ssl-cert-snakeoil.key
DocumentRoot /var/www/html
<Directory /var/www/html>
Options None
AllowOverride None
</Directory>
<Location />
Require all denied
</Location>
</VirtualHost>
</IfModule>
Define a Virtual Host for HTTP
Create a virtual host configuration for the target domain. In this example, replace example.com with the actual domain name.
sudo nano /etc/apache2/sites-available/example-com.conf
<VirtualHost *:80>
ServerName example.com
ServerAlias www.example.com
DocumentRoot /www/example-com/public_html
<Directory "/www/example-com/public_html">
Options FollowSymLinks
AllowOverride None
Require all granted
</Directory>
ErrorLog ${APACHE_LOG_DIR}/error.log
CustomLog ${APACHE_LOG_DIR}/access.log combined
<IfModule mod_rewrite.c>
# Redirect HTTP to HTTPS on the same host; the HTTPS virtual host applies the canonical redirect
RewriteEngine On
RewriteRule ^ https://%{SERVER_NAME}%{REQUEST_URI} [R=permanent,L]
</IfModule>
</VirtualHost>
The AllowOverride None directive at this stage is intentional. .htaccess file processing carries a performance cost because Apache must check for the file in every directory it traverses to serve a request. Since this virtual host immediately redirects all traffic to HTTPS without serving any content, .htaccess processing here would add overhead with no benefit. AllowOverride All is set in the HTTPS virtual host in a later step, where application rules actually need to take effect.
Canonical URL Preference
This article uses www.example.com as the canonical domain, meaning all requests to example.com are permanently redirected to www.example.com. The HTTP virtual host redirects to HTTPS on the same hostname, and the HTTPS virtual host then applies the canonical redirect. If example.com is preferred as canonical, the HTTP virtual host needs no change. Only the rewrite block in the HTTPS virtual host changes.
Create the DocumentRoot and Set File Permissions
The DocumentRoot referenced in the virtual host configuration does not exist yet. The path /www/example-com/public_html is used here to keep application content cleanly separated from the default Apache directory tree under /var/www/. Either location works as long as the virtual host configuration matches. Adjust the path as needed before running the commands below.
Create the directory:
sudo mkdir -p /www/example-com/public_html
Set ownership to the www-data user and group that Apache runs under:
sudo chown -R www-data:www-data /www/example-com/public_html
NOTE: PHP-FPM runs as
www-datain the default pool, so a compromised PHP process can modify application files owned bywww-data. A stricter layout assigns file ownership to a deployment user, grantswww-dataread access, and makes only upload directories writable. This trade-off is weighed against WordPress self-updating, which requires write access. A dedicated PHP-FPM pool per site can further isolate applications.
Set standard permissions: 755 for directories and 644 for files:
sudo find /www/example-com/public_html -type d -exec chmod 755 {} \;
sudo find /www/example-com/public_html -type f -exec chmod 644 {} \;
These find commands are safe to run on an empty DocumentRoot at setup time. Running them again later on a live WordPress installation will correctly reset permissions on existing content, but any directories created by WordPress during that interval (such as dated upload folders under wp-content/uploads/) will already carry the correct ownership from the www-data process that created them. The commands do not break anything when re-run. The caveat is that they operate on the state of the filesystem at the moment they execute.
Enable the New Configurations
sudo a2enconf custom.conf
sudo a2ensite 000-default.conf
sudo a2ensite 000-default-ssl.conf
sudo a2ensite example-com.conf
sudo apache2ctl configtest
sudo systemctl reload apache2
NOTE: The
000-default.confsite is already enabled by default on a fresh Apache installation, soa2ensite 000-default.confwill outputSite 000-default already enabled. This is expected and not an error; the command is included to ensure the updated configuration is active if the file was previously disabled.
reload is used here rather than restart because it sends a graceful signal that re-reads the configuration without dropping any active connections. During the setup phase the difference is academic since no traffic is flowing, but using reload where a full restart is not required is the correct habit for a live server.
Verify the LAMP Stack
With all the basic components in place, run a verification pass.
Apache Configuration Syntax Check
sudo apache2ctl configtest
The output should end with Syntax OK.
PHP-FPM Status
sudo systemctl status php8.4-fpm
MariaDB Status
sudo systemctl status mariadb
Verify the PHP Version
php -v
The CLI binary reads /etc/php/8.4/cli/php.ini, so php -v does not reflect the PHP-FPM settings changed earlier. The phpinfo test below reads the FPM configuration.
Test PHP-FPM Over HTTP
Without DNS configured or an SSL certificate provisioned, verify that Apache is serving PHP correctly by testing over HTTP using localhost directly on the server.
Temporarily remove the HTTP-to-HTTPS rewrite rule.
sudo nano /etc/apache2/sites-available/example-com.conf
# <IfModule mod_rewrite.c>
# Redirect HTTP to HTTPS on the same host; the HTTPS virtual host applies the canonical redirect
# RewriteEngine On
# RewriteRule ^ https://%{SERVER_NAME}%{REQUEST_URI} [R=permanent,L]
# </IfModule>
Reload Apache:
sudo apache2ctl configtest && sudo systemctl reload apache2
Confirm Apache uses PHP-FPM by creating a temporary info file:
echo "<?php phpinfo(); ?>" | sudo tee /www/example-com/public_html/phpinfo.php
Then run the following from the server command line:
curl -s -H 'Host: example.com' http://localhost/phpinfo.php | grep -E 'Server API|memory_limit|upload_max_filesize'
NOTE: If
curlis not available, install it usingsudo apt install curl.
The output should show FPM/FastCGI, 256M, and 64M.
When finished, restore the rewrite rule, reload Apache again, and remove the temporary info file before continuing.
sudo nano /etc/apache2/sites-available/example-com.conf
<IfModule mod_rewrite.c>
# Redirect HTTP to HTTPS on the same host; the HTTPS virtual host applies the canonical redirect
RewriteEngine On
RewriteRule ^ https://%{SERVER_NAME}%{REQUEST_URI} [R=permanent,L]
</IfModule>
Reload Apache:
sudo apache2ctl configtest && sudo systemctl reload apache2
Remove the temporary info file:
sudo rm /www/example-com/public_html/phpinfo.php
Install Certbot and Provision a Let’s Encrypt SSL Certificate
Let’s Encrypt provides free, automatically renewed SSL/TLS certificates. Certbot is the official client for Let’s Encrypt and integrates directly with Apache to configure HTTPS virtual hosts.
NOTE: A domain name pointed at the server’s static IP address is required to complete this section. If DNS is not ready, stop here. The Verify the LAMP Stack section above confirms Apache, PHP-FPM, and MariaDB without a domain, so the server can remain in that state until DNS is in place.
Before running Certbot, DNS records for both the domain and its www hostname must resolve to the Lightsail instance’s static IP address. Let’s Encrypt verifies control of each hostname during certificate issuance by making an HTTP request to the domain. If either hostname does not resolve correctly, or an existing IPv6 (AAAA) record reaches a different server, certificate issuance can fail.
Install Certbot via Snap
Remove any previous APT-based Certbot installation to avoid conflicts:
sudo apt remove certbot
Install snapd and ensure the service is fully running before issuing snap commands. On some Lightsail instances, snapd does not start automatically after the apt install, and snap commands fail with a socket error if run immediately:
sudo apt install snapd -y
sudo systemctl enable --now snapd
Install and refresh the core snap. On a fresh install these two commands are run together: the first installs core if it is not present, the second ensures it is current:
sudo snap install core; sudo snap refresh core
Install Certbot:
sudo snap install --classic certbot
Create a symbolic link so the certbot command is available system-wide:
sudo ln -sf /snap/bin/certbot /usr/local/bin/certbot
Request the Certificate
Run Certbot to request a certificate covering both domains:
sudo certbot --apache --no-redirect -d example.com -d www.example.com
The --no-redirect option prevents Certbot from adding its own redirect to the HTTP virtual host, because the redirect is already defined above. Certbot creates and enables example-com-le-ssl.conf automatically.
The first -d argument determines the directory name created under /etc/letsencrypt/live/ (conventionally the bare domain, regardless of which form is canonical). The canonical redirect direction is a separate decision handled entirely by the rewrite rule in the HTTPS virtual host.
This creates a certificate covering both example.com and www.example.com, with the live files stored under /etc/letsencrypt/live/example.com/.
Certbot will prompt for an e-mail address and confirmation of the Let’s Encrypt Terms of Service. After completing the prompts, Certbot verifies ownership of the domain and issues the certificate.
Let’s Encrypt certificates are short-lived, and lifetimes may continue to shorten. Certbot configures a systemd timer to automatically renew certificates before they expire. Renewal depends on the domain name resolving to the server, so if the static IP is ever detached or DNS is not kept pointing at the server, the renewal HTTP challenge will fail. Let’s Encrypt no longer sends expiration notification e-mails, so a failed renewal does not produce a warning. Check the renewal timer and /var/log/letsencrypt/letsencrypt.log periodically, or use a third-party certificate expiry monitor.
Test the renewal process to confirm it is working:
sudo certbot renew --dry-run
Verify the renewal timer is active:
systemctl list-timers | grep certbot
Configure the HTTPS Virtual Host With HSTS
After Certbot issues the certificate, it creates a new virtual host configuration file at /etc/apache2/sites-available/example-com-le-ssl.conf. Replace the contents of this file with the following. The certificate paths match the defaults Certbot used.
sudo nano /etc/apache2/sites-available/example-com-le-ssl.conf
<IfModule mod_ssl.c>
<VirtualHost *:443>
ServerName example.com
ServerAlias www.example.com
DocumentRoot /www/example-com/public_html
<Directory "/www/example-com/public_html">
Options FollowSymLinks
AllowOverride All
Require all granted
</Directory>
ErrorLog ${APACHE_LOG_DIR}/error.log
CustomLog ${APACHE_LOG_DIR}/access.log combined
# Redirect non-www requests to www
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteCond %{HTTP_HOST} ^example\.com(:\d+)?$ [NC]
RewriteRule ^ https://www.example.com%{REQUEST_URI} [R=permanent,L]
</IfModule>
Include /etc/letsencrypt/options-ssl-apache.conf
SSLCertificateFile /etc/letsencrypt/live/example.com/fullchain.pem
SSLCertificateKeyFile /etc/letsencrypt/live/example.com/privkey.pem
# HTTP Strict Transport Security; start with a short max-age and increase after verification
# includeSubDomains applies HSTS to all subdomains; remove if any subdomains are not HTTPS-capable
<IfModule mod_headers.c>
Header always set Strict-Transport-Security "max-age=300; includeSubDomains"
Header always set X-Content-Type-Options "nosniff"
Header always set Referrer-Policy "strict-origin-when-cross-origin"
Header always set X-Frame-Options "SAMEORIGIN"
</IfModule>
</VirtualHost>
</IfModule>
The rewrite block above redirects bare domain requests to www. If the bare domain is preferred as canonical, replace this block with the following:
# Redirect www requests to non-www
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteCond %{HTTP_HOST} ^www\.(.+)$ [NC]
RewriteRule ^ https://%1%{REQUEST_URI} [R=permanent,L]
</IfModule>
The %1 back-reference captures the domain without the www. prefix from the RewriteCond match and uses it to build the redirect target. Choose one canonical form (www or non-www) and apply it consistently across DNS records, virtual host configuration, the WordPress Site URL setting, and any sitemaps or canonical tags in the theme. See the Canonical URL Preference section above for more detail.
AllowOverride All
This directive enables .htaccess directives permitted by Apache’s override rules within the DocumentRoot directory tree. With AllowOverride None and no applicable AllowOverrideList, Apache ignores .htaccess files in that directory tree. The trade-off is additional per-request overhead, which is generally modest.
The server-level directives in custom.conf suppress version information globally but do not provide directory-level protections. An .htaccess file can extend those protections, as demonstrated in Harden WordPress Security Using Custom .htaccess Files. The AllowOverride All setting is broader than necessary for the directives in that example. A narrower configuration is possible, but must permit the directives required by the site’s .htaccess rules and WordPress plugins.
If the application does not require .htaccess, AllowOverride None with configuration moved into the virtual host is preferable.
HSTS
The Strict-Transport-Security header instructs browsers to only connect to this domain over HTTPS for the duration specified in max-age. Browsers cache this header for the full max-age, so a mistake at a long value is difficult to undo. Start with max-age=300, then raise it to 604800 (one week), 2592000 (one month), and finally 63072000 (two years) after confirming that every subdomain serves HTTPS correctly at each stage. Reload Apache after each change.
The includeSubDomains directive extends this enforcement to all subdomains; remove it if any subdomains on this domain are not served over HTTPS. Browser preload lists require includeSubDomains, preload, max-age of at least one year, HTTPS on the apex domain, and an HTTP to HTTPS redirect that stays on the same host before any other redirect.
To submit the domain to browser HSTS preload lists, add the preload directive:
Header always set Strict-Transport-Security "max-age=63072000; includeSubDomains; preload"
Adding preload hardcodes HTTPS enforcement in browsers before the first visit. Only add preload after verifying that every current and future subdomain can be served exclusively over HTTPS. Once accepted into browser preload lists, removal can take months and may temporarily break access to subdomains that are not HTTPS-capable.
Security Headers
The virtual host above also sets three baseline response headers. X-Content-Type-Options prevents browsers from guessing content types, Referrer-Policy limits the referrer information sent to other sites, and X-Frame-Options blocks embedding in frames on other sites. Remove X-Frame-Options if the site must be embedded elsewhere. Content-Security-Policy and Permissions-Policy depend on the application and are excluded here.
Reload Apache:
sudo apache2ctl configtest
sudo systemctl reload apache2
Verify HTTPS, HTTP/2, and Redirects
Replace example.com and SERVER_IP with the actual domain and server address. Verify HTTPS, HTTP/2, and redirects are active:
curl -sI -o /dev/null -w '%{http_version}\n' https://www.example.com
curl -sIL -o /dev/null -w '%{num_redirects} %{url_effective}\n' http://example.com
curl -sI https://www.example.com | grep -iE '^(server|x-powered-by|strict-transport-security|x-content-type-options|referrer-policy|x-frame-options)'
curl -s -o /dev/null -w '%{http_code}\n' -X TRACE https://www.example.com
curl -s -o /dev/null -w '%{http_code}\n' http://SERVER_IP
curl -sk -o /dev/null -w '%{http_code}\n' https://SERVER_IP
Expected results: 2, 2 https://www.example.com/, a server: Apache header with no X-Powered-By and HSTS plus security headers, 405, 403 for HTTP by IP, and 403 for HTTPS by IP.
NOTE: The local
curlinstallation must support HTTP/2; otherwise, the command may report HTTP/1.1 or fail to negotiate HTTP/2. The-koption disables certificate verification because the request uses the server’s IP address rather than a hostname covered by the certificate. This test verifies the default HTTPS virtual host’s access restriction. It does not verify certificate validity.
Troubleshoot Unexpected Results
If any result differs from the expected output, list how Apache matched each virtual host:
sudo apache2ctl -S
The output lists a default server for each port. The default for port 80 should point to 000-default.conf, and the default for port 443 should point to 000-default-ssl.conf. The domain’s virtual hosts appear below each default. Then review the most recent Apache errors:
sudo tail -n 30 /var/log/apache2/error.log
Disk Usage Comparison
df -h
Compare this output to the baseline recorded at the start of the guide. At minimum, it serves as a reference for identifying whether logs or other maintenance activities are growing out of control and unnecessarily consuming storage.
Results
At this point the server is running Debian 13 with Apache, PHP 8.4 via PHP-FPM, and MariaDB. HTTP requests are redirected to HTTPS. HTTPS connections benefit from HTTP/2 and HSTS. Server version information is suppressed from response headers and error pages. The default virtual host denies all direct HTTP and HTTPS requests by IP address. MariaDB anonymous users, the test database, and remote root login are all removed. PHP production settings for memory, upload size, and execution time have been adjusted from their conservative defaults, and PHP version fingerprinting is confirmed suppressed.
The core platform is now configured for use, but additional security hardening and operational work remains before the server is exposed to public traffic. The following section covers both.
Additional Considerations
The steps in this section fall into two groups. The security hardening items should be completed before the server goes live. The operations items establish the ongoing practices needed to keep it running safely.
Security Hardening
Abuse Protection With Fail2ban
WordPress login pages and XML-RPC endpoints are subjected to constant automated brute-force attempts regardless of how small or new a website is. Restricting wp-login.php by IP in .htaccess is the strongest defense, but Fail2ban provides a complementary layer that monitors Apache logs and temporarily bans IPs that generate excessive failed requests.
The four Apache jails configured below operate at the server level, monitoring Apache error and access logs for patterns such as authentication failures, known bad bot signatures, requests for non-existent scripts, and request overflow attacks. They do not natively detect WordPress login failures, because WordPress processes those at the application level and returns an HTTP 200 response rather than an Apache-level authentication error. For WordPress-specific login protection, IP restriction via .htaccess or a dedicated WordPress Fail2ban filter is required. The jails here still provide limited, server-level coverage against automated scanning and similar attacks.
Install Fail2ban:
sudo apt install -y fail2ban
Create a local override configuration file. Fail2ban uses a jail.local file to override defaults without modifying the packaged jail.conf, which may be overwritten on upgrades:
sudo nano /etc/fail2ban/jail.local
Add the following configuration:
[DEFAULT]
bantime = 3600
findtime = 600
maxretry = 5
ignoreip = 127.0.0.1/8 ::1 203.0.113.10
[apache-auth]
enabled = true
[apache-badbots]
enabled = true
[apache-noscript]
enabled = true
[apache-overflows]
enabled = true
This configuration bans an IP for one hour after five failed attempts within a ten-minute window, and enables four Apache-specific filters that catch authentication failures, known bad bots, requests for non-existent scripts, and request overflow attacks.
Replace 203.0.113.10 with the administrator’s public IP address. This exempts connections from that IP address from Fail2ban bans.
Enable and start Fail2ban:
sudo systemctl enable fail2ban
sudo systemctl start fail2ban
Verify that the jails are active:
sudo fail2ban-client status
Restrict or Disable XML-RPC
WordPress’s xmlrpc.php endpoint enables remote publishing and certain plugin integrations, but it is also a persistent target for brute-force amplification attacks; a single XML-RPC request can contain multiple authentication attempts. For most small WordPress sites that do not use the Jetpack plugin or remote publishing clients, XML-RPC can be blocked entirely at the Apache virtual host level.
Add the following Location block inside the HTTPS virtual host configuration in /etc/apache2/sites-available/example-com-le-ssl.conf, within the <VirtualHost> directive:
# Block XML-RPC entirely; remove if Jetpack or remote publishing is required
<Location /xmlrpc.php>
Require all denied
</Location>
If XML-RPC access is needed from a client with a fixed IP address, allow only that address:
<Location /xmlrpc.php>
<RequireAny>
Require ip 203.0.113.1
</RequireAny>
</Location>
Replace 203.0.113.1 with the actual public IP address that requires XML-RPC access. Multiple Require ip lines can be added for additional addresses.
After editing the virtual host file, reload Apache:
sudo apache2ctl configtest
sudo systemctl reload apache2
NOTE: Additional WordPress hardening measures are covered in the Harden WordPress Security Using Custom .htaccess Files article, including locking
wp-login.phpandxmlrpc.phpby IP at the directory level and blocking PHP execution in thewp-content/uploads/directory (preventing malicious files uploaded through a vulnerable plugin from being executed by the server).
Rate Limiting With mod_evasive
For basic protection against simple HTTP flood attacks, mod_evasive monitors request rates per IP and returns a 403 response when thresholds are exceeded. It is a lightweight option well-suited to a small Lightsail instance where a full WAF would be disproportionate.
sudo apt install -y libapache2-mod-evasive
sudo a2enmod evasive
sudo apache2ctl configtest
sudo systemctl restart apache2
The built-in default thresholds can be strict for pages that load many assets over HTTP/2. If legitimate visitors receive 403 responses, add and raise DOSPageCount and DOSSiteCount in /etc/apache2/mods-available/evasive.conf.
The DOSEmailNotify directive makes mod_evasive attempt to send an e-mail each time it bans an IP. AWS blocks outbound mail on port 25 by default on Lightsail, and many other hosting providers apply similar restrictions, so unless the server has a configured mail transfer agent and outbound mail is permitted, these notifications cannot be delivered and will generate mail delivery errors in the system log. Check whether the directive is active:
grep -n 'DOSEmailNotify' /etc/apache2/mods-available/evasive.conf
If the line begins with #, no change is needed. If it does not, open /etc/apache2/mods-available/evasive.conf, comment out the line (#DOSEmailNotify you@yourdomain.com), and restart Apache.
For sites that attract more targeted traffic or need more granular control, ModSecurity, a full web application firewall (WAF), is the next step up, though its configuration overhead is significant and best suited to higher-risk deployments.
Operations
Log Rotation
Apache on Debian installs a logrotate configuration by default, but logrotate itself may not be present on a minimal Debian image. Install it if it is not already available:
sudo apt install -y logrotate
This also ensures the Apache logrotate configuration at /etc/logrotate.d/apache2 is registered and active. On a small Lightsail instance with limited disk space, unrotated logs from a busy or misbehaving website can fill the disk quickly. Confirm log rotation is configured correctly:
sudo logrotate --debug /etc/logrotate.d/apache2
The output shows what logrotate would do on its next run without actually rotating anything.
phpMyAdmin
phpMyAdmin is a browser-based MariaDB administration interface. Many managed hosting providers install it by default, but it is not recommended on a self-managed public-facing server. Installing phpMyAdmin on the web server adds a publicly accessible administrative interface that is a frequent target for automated scanning and credential stuffing attacks. If database administration access is needed, HeidiSQL is a capable free Windows client that connects to the database server over an SSH tunnel, keeping the administrative interface entirely off the public-facing server.
Backups
A server built from scratch with no backup process is a server that cannot be recovered. At minimum, configure automated backups that copy the database and the DocumentRoot off-instance on a regular schedule. Lightsail manual snapshots are a starting point, but they are not a substitute for file-level backups that can be selectively restored. For a practical implementation, read Automated Linux Web Server Backup Script to Google Drive, which covers a scriptable approach to file and database dumps that can be scheduled via cron.
Periodically test restoring both a database backup and application files; a backup that cannot be restored is not a reliable recovery mechanism.
Ongoing Maintenance
Keep the instance updated. The command used at the start of this guide (sudo apt update && sudo apt upgrade -y) should be run regularly, or automated (read Automate Maintenance on a Debian LAMP Stack Using a Bash Script for a scriptable approach).
Security updates for Debian-packaged software, including PHP, can be applied automatically with unattended-upgrades:
sudo apt install -y unattended-upgrades
sudo dpkg-reconfigure -plow unattended-upgrades
sudo unattended-upgrade --dry-run --debug
The permitted origins are listed under Unattended-Upgrade::Origins-Pattern in /etc/apt/apt.conf.d/50unattended-upgrades. Review the list instead of assuming which repositories it covers:
sed -n '/^Unattended-Upgrade::Origins-Pattern/,/^};/p' /etc/apt/apt.conf.d/50unattended-upgrades | grep -v '^[[:space:]]*//'
On a default installation, the active entries cover the main Debian 13 archive and the Debian security archive. The -updates, -proposed-updates, and backports entries are commented out.
A third-party repository, such as the Sury repository used to run a newer PHP release than Debian ships, must be added to that block before it receives automatic updates. This article does not use that repository.
Unattended upgrades install kernel updates but do not reboot the instance. Compare the running kernel with the newest installed kernel:
uname -r
ls /boot/vmlinuz-* | sort -V | tail -n 1
If the versions differ, reboot during a maintenance window.
Monitor PHP version support timelines and plan major version upgrades before the active support window closes.
Summary
Starting from a bare Debian image results in a LAMP stack built from independently controlled and upgradable components. Apache, PHP, and MariaDB are configured intentionally rather than inherited from a pre-packaged image, making long-term maintenance significantly easier.
The result is an Apache server with minimal exposure, PHP via PHP-FPM with production-oriented configuration and optional security hardening, a secured MariaDB instance with a least-privilege application user, SSL/TLS with automatic certificate management, a default virtual host that denies requests by IP address, baseline security headers, Fail2ban Apache jails for scanning and abuse, XML-RPC locked down at the virtual host level, and lightweight flood protection via mod_evasive. The backup and maintenance sections provide the operational foundation to keep the server running long-term.