Debian 13 (Trixie) server set-up
Debian 13 has been out a while now and so I guess it is time for me to update my web server set-up guide.
There are lots of ways to set-up Debian 13 as a web server, I tend to go for a vanilla'ish Apache, MariaDB and PHP approach. This guide assumes you have already installed the base operating system and have shell access with sudo privileges.
Step 1: Install Packages
Run the following command to install the packages:
sudo apt install git curl bat apache2 apache2-bin apache2-data apache2-utils mariadb-client mariadb-server php php-fpm php8.4 php-common php-gd php-getid3 php-mysql php8.4-fpm php8.4-cli php8.4-common php8.4-gd php8.4-mysql php-ldap php8.4-redis php8.4-opcache php8.4-soap php8.4-readline php8.4-curl php8.4-xml php-imagick php8.4-intl php8.4-zip php8.4-mbstring ssl-cert imagemagick php-imagick wget zip unzip redis-server php-curl php-bcmath php-gmp php-sqlite3 sqlite3 certbot ufw fail2ban
Step 2: Configure Apache and PHP
Configure Apache to talk to PHP-FPM, activate the Apache config that connects Apache to PHP 8.4 via PHP-FPM (this is the modern alternative to mod_php) and enable rewrite, ssl, http2 and headers modules.
sudo a2enmod proxy_fcgi setenvif && sudo a2enconf php8.4-fpm && sudo systemctl reload apache2 && sudo a2enmod rewrite && sudo a2enmod ssl && sudo a2enmod http2 && sudo a2enmod headers && sudo systemctl restart apache2
Tune PHP for larger uploads (128MB), longer-running scripts and more memory-heavy applications. Note, these values are quite generous, so adjust to suit.
; --- Recommended production changes ---
expose_php = Off ; Hides PHP version in HTTP headers to prevent version-specific attacks
max_execution_time = 120 ; Limits script runtime (seconds) to prevent hung processes from hogging CPU
max_input_time = 120 ; Max time (seconds) a script is allowed to parse input data (like POST/GET)
memory_limit = 256M ; Max amount of RAM a single script is allowed to consume
post_max_size = 64M ; Maximum size allowed for the entire POST body (must be >= upload_max_filesize)
upload_max_filesize = 64M ; Maximum size allowed for a single uploaded file
max_file_uploads = 20 ; Maximum number of files that can be uploaded in one request
max_input_vars = 3000 ; Limits number of input variables (GET/POST/Cookie) to prevent Hash DoS attacks
date.timezone = Europe/London ; Sets the default timezone for all date/time functions
display_errors = Off ; Prevents error details from being shown to users (security risk in prod)
display_startup_errors = Off ; Hides errors that occur during PHP's startup sequence
log_errors = On ; Ensures errors are recorded to a log file for debugging
realpath_cache_size = 4096k ; Size of the cache used by PHP to store file path resolutions for speed
realpath_cache_ttl = 600 ; How long (seconds) to cache file path information
session.use_strict_mode = 1 ; Prevents session fixation attacks by rejecting uninitialized session IDs
session.cookie_httponly = 1 ; Makes cookies inaccessible to JS, mitigating Cross-Site Scripting (XSS)
session.cookie_samesite = Lax ; Restricts cookie sending to same-site requests to help prevent CSRF
mysqli.allow_persistent = Off ; Disables persistent MySQL connections to avoid leaking connection slots
pgsql.allow_persistent = Off ; Disables persistent PostgreSQL connections
odbc.allow_persistent = Off ; Disables persistent ODBC connections
[opcache]
opcache.enable=1 ; Enables the opcode cache to store precompiled script bytecode in RAM
opcache.enable_cli=0 ; Disables opcode caching for the Command Line Interface (usually unnecessary)
opcache.memory_consumption=256 ; Amount of RAM (MB) dedicated to storing compiled PHP scripts
opcache.interned_strings_buffer=16 ; RAM (MB) used to store identical strings (like variable names) once
opcache.max_accelerated_files=20000 ; Max number of script files that can be cached (aim for prime numbers)
opcache.max_wasted_percentage=10 ; Max wasted memory allowed before a restart is scheduled
opcache.use_cwd=1 ; Uses current working directory to avoid script collisions with same names
opcache.validate_timestamps=1 ; Tells PHP to check if a file has changed based on its timestamp
opcache.revalidate_freq=2 ; How often (seconds) to check file timestamps for changes
opcache.save_comments=1 ; Keeps file comments; required by many modern frameworks for annotations
opcache.enable_file_override=0 ; Prevents OPcache from overriding file_exists() and similar calls
opcache.optimization_level=0x7FFEBFFF; Bitmask determining which internal compiler optimizations are applied
opcache.file_update_protection=2 ; Prevents caching files that were modified less than X seconds ago
Save the file and restart the PHP service:
sudo systemctl restart php8.4-fpm
Step 3: Install Node and Composer
Install Node LTS with the following commands:
curl -fsSL https://deb.nodesource.com/setup_24.x | sudo -E bash - &&\
sudo apt-get install -y nodejs
sudo npm install -g grunt-cli eslint
Install Composer with the following command:
php -r "copy('https://getcomposer.org/installer', 'composer-setup.php');"
php composer-setup.php
php -r "unlink('composer-setup.php');"
sudo mv composer.phar /usr/local/bin/composer
Step 4: Configure MariaDB
Create a new user:
sudo mariadb
CREATE USER 'username'@'localhost' IDENTIFIED BY 'password';
GRANT ALL PRIVILEGES ON *.* TO 'username'@'localhost' WITH GRANT OPTION;
Create MariaDB user config file (this prevents having to enter passwords when using mysql and mysqldump on the command line, handy for automating backups with cron):
nano ~/.my.cnf
Enter the following, changing the username and password to suit:
[mysql]
user = username
password = secret
[mysqldump]
user = username
password = secret
Fix permissions on the file with the following command:
chmod 600 ~/.my.cnf
Edit MariaDB config file for performance:
sudo nano /etc/mysql/mariadb.conf.d/50-server.cnf
Use something similar to the below, adjust to suit server resources:
[mysqld]
# GENERAL
bind-address = 127.0.0.1 ; Restricts MySQL to listen only on localhost for security
max_connections = 200 ; Limits the total number of simultaneous client connections
# INNODB (most important)
innodb_buffer_pool_size = 12G ; Main memory cache for data/indexes (usually set to 70-80% of total RAM)
innodb_buffer_pool_instances = 8 ; Splits the buffer pool into chunks to reduce contention on multi-core systems
innodb_log_file_size = 1G ; Size of redo logs; larger values improve write speed but slow down recovery
innodb_flush_method = O_DIRECT ; Flushes data directly to disk, bypassing the OS cache to avoid double buffering
innodb_flush_log_at_trx_commit = 1 ; Ensures ACID compliance by flushing every transaction to disk (safest setting)
# CACHE
query_cache_type = 0 ; Disables the legacy Query Cache (deprecated/removed in newer MySQL versions)
query_cache_size = 0 ; Sets Query Cache memory to zero to prevent overhead/locking issues
# TEMP / TABLES
tmp_table_size = 256M ; Max size of internal in-memory temporary tables before they flip to disk
max_heap_table_size = 256M ; Must match tmp_table_size to effectively handle large in-memory operations
# THREADS
thread_cache_size = 100 ; How many threads are kept in a pool to be reused for new connections
# FILE LIMITS
open_files_limit = 65535 ; Max number of file descriptors the OS allows MySQL to open
table_open_cache = 4000 ; Number of open tables MySQL can keep cached to avoid repeated file opening
# LOGGING (optional but useful)
slow_query_log = 1 ; Enables logging for queries that take a long time to execute
slow_query_log_file = /var/log/mysql/slow.log ; Defines the physical path where the slow query log is saved
long_query_time = 2 ; Threshold (seconds) for what defines a "slow" query
Step 5: File and Directory Permissions
I tend to serve websites from my user's home directory. I use a shared group so both my user and the httpd user can read and write to the directories and files. Create a shared group with:
sudo groupadd webshared
Add both users to the group:
sudo usermod -aG webshared username
sudo usermod -aG webshared www-data
Log out/in (or newgrp webshared) for it to take effect.
Next, change the ownership of the web directory and set the permissions to group write:
sudo chown -R username:webshared /home/username/public_html
sudo chmod -R 2775 /home/username/public_html
Ensure new files stay writable (umask), set:
umask 002
(in ~/.zshrc or ~/.bashrc or ~/.profile etc)
For the www-data user, edit the config:
sudo nano /etc/php/8.4/fpm/pool.d/www.conf
Adding:
php_admin_value[umask] = 0002
The full file should look something like:
[www] ; Defines the name of this specific process pool
user = www-data ; The Unix user that the PHP processes will run as
group = www-data ; The Unix group that the PHP processes will run as
php_admin_value[umask] = 0002 ; Sets default file permissions for new files (gives group write access)
listen = /run/php/php8.4-fpm.sock ; Path to the Unix socket for Nginx/Apache to talk to PHP
listen.owner = www-data ; Owner of the socket file (must match web server user)
listen.group = www-data ; Group of the socket file
listen.mode = 0660 ; Permissions for the socket (owner and group can read/write)
listen.backlog = 1024 ; Max number of queued connection requests before refusal
pm = dynamic ; Enables dynamic scaling of child processes based on demand
pm.max_children = 40 ; Max number of simultaneous child processes (prevents RAM exhaustion)
pm.start_servers = 8 ; Number of child processes created on startup
pm.min_spare_servers = 4 ; Minimum number of idle processes kept ready for spikes
pm.max_spare_servers = 12 ; Maximum number of idle processes kept alive
pm.max_requests = 500 ; Re-spawns a child after X requests to prevent memory leaks
pm.status_path = /fpm-status ; URI to view real-time stats (load, active processes, etc.)
ping.path = /fpm-ping ; URI used by monitoring tools to check if PHP is alive
ping.response = pong ; The specific text response returned by the ping path
request_slowlog_timeout = 10s ; Log requests that take longer than 10 seconds to execute
slowlog = /var/log/php8.4-fpm/www-slow.log ; File path where slow execution stack traces are saved
request_terminate_timeout = 180s ; Force-kill processes that run longer than 3 minutes
request_terminate_timeout_track_finished = yes ; Log if a process finished successfully after the timeout was reached
catch_workers_output = yes ; Redirects script stdout/stderr to the main FPM error log
clear_env = yes ; Clears environment variables to prevent data leakage between scripts
security.limit_extensions = .php ; Limits PHP execution to specific extensions for security
php_admin_flag[log_errors] = on ; Forces error logging for this specific pool
php_admin_value[error_log] = /var/log/php8.4-fpm/www-error.log ; Path to the error log for this pool
Step 6: Auto/Unattended Upgrades
Probably best to keep the server software up-to-date.
sudo apt install unattended-upgrades apt-listchanges && sudo dpkg-reconfigure --priority=low unattended-upgrades
Step 7: Firewall
Configure the firewall to allow SSH, HTTP and HTTPS, before enabling it:
sudo ufw allow OpenSSH
sudo ufw allow WWW
sudo ufw allow "WWW Secure"
sudo ufw enable
sudo ufw status verbose
You can check the firewall status at any time with the following command:
sudo ufw status
Step 8: Fail2Ban
Create a custom config file to work with the ufw firewall:
sudo nano /etc/fail2ban/jail.local
Enter the following:
[DEFAULT]
bantime = 1h
findtime = 10m
maxretry = 5
banaction = ufw
[sshd]
enabled = true
Enable and start the service:
sudo systemctl enable fail2ban
sudo systemctl start fail2ban
Step 9: Misc
Set hostname
You may or may not need to adjust the system's hostname:
sudo hostnamectl set-hostname new-hostname
Set timezone
You may or may not need to adjust the server's timezone:
sudo timedatectl set-timezone Europe/London
Finishing touches
Finishing touches might include things such as setting a different shell, importing or creating SSH keys etc. Other than that, any other changes would be specific to the applications being hosted or developed on the server.