# 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.