Debian 13, minimum ram and disk, 38mb - Guide - Part 2 (running wordpress)
AnthonySmith
ModeratorHosting ProviderOGSenpai
WordPress on Debian 13: Minimal VPS
This guide installs a tuned WordPress stack on a minimised Debian 13 VPS, picking up from the end of the https://lowendspirit.com/discussion/10684/debian-13-minimum-ram-and-disk-38mb-guide-part-1-running-nothing#latest guide. Every command has been run on a real instance and the results recorded.
AI USE Declaration: md formatting, spelling, grammar done with Claude, the work and methods are my own.
The stack is Nginx with FastCGI caching, MariaDB 10.11 LTS, and PHP 8.4-FPM. The target is the lowest practical idle RAM footprint on a 256MB VPS without sacrificing WordPress compatibility.
Before
End state of the Debian 13 minimisation guide.
total used free shared buff/cache available
Mem: 213 38 141 0 41 174
Swap: 0 0 0
Filesystem Size Used Avail Use% Mounted on
/dev/vda1 2.8G 275M 2.4G 11% /
Even if you did not follow that guide, this should give you some ideas on reducing the footprint for your small WordPress setup.
Stage 1: Install MariaDB 10.11 LTS
Debian 13 (trixie) ships MariaDB 11.8. This guide uses MariaDB 10.11 LTS from the MariaDB Foundation repository. On this configuration, 10.11 uses 57MB RSS at idle versus 68MB for 11.8.
Installing 10.11 on Debian 13 requires three steps before the main package install: a compatibility shim for libaio1 (renamed in Debian 13's library transition), the MariaDB Foundation repository, and an apt preference to pin the packages.
libaio1 compatibility shim. Debian 13 renamed libaio1 to libaio1t64. The MariaDB 10.11 bookworm packages declare a dependency on the old name. Create a symlink so the library is found at runtime, then build a dummy package to satisfy the dependency declaration:
apt-get update
apt-get install -y gnupg equivs libaio1t64
ln -s /usr/lib/x86_64-linux-gnu/libaio.so.1t64 /usr/lib/x86_64-linux-gnu/libaio.so.1
ldconfig
cat > /tmp/libaio1-compat << 'EOF'
Section: libs
Priority: optional
Standards-Version: 3.9.2
Package: libaio1
Version: 0.3.113-8+b1
Provides: libaio1
Depends: libaio1t64
Architecture: amd64
Description: libaio1 compatibility shim for Debian 13
EOF
cd /tmp && equivs-build libaio1-compat
dpkg -i /tmp/libaio1_0.3.113-8+b1_amd64.deb
MariaDB Foundation repository and apt pin:
curl -LsS https://mariadb.org/mariadb_release_signing_key.asc \
| gpg --dearmor > /etc/apt/trusted.gpg.d/mariadb.gpg
cat > /etc/apt/sources.list.d/mariadb.list << 'EOF'
deb [arch=amd64,arm64] https://dlm.mariadb.com/repo/mariadb-server/10.11/repo/debian bookworm main
EOF
cat > /etc/apt/preferences.d/mariadb << 'EOF'
Package: mariadb-* libmariadb*
Pin: release n=trixie
Pin-Priority: -1
EOF
apt-get update
The pin sets the priority of Debian's trixie mariadb packages to -1, which prevents apt from ever selecting them, including during apt full-upgrade.
Remove build tools. equivs pulls in the full compiler and build toolchain as dependencies. gnupg is no longer needed once the signing key is imported. Remove them now before the main install to keep the disk footprint clean:
apt-get purge -y equivs gnupg dpkg-dev build-essential && apt-get autoremove -y --purge
Install the stack:
DEBIAN_FRONTEND=noninteractive apt-get install -y \
nginx mariadb-server-core mariadb-client-core \
php8.4-fpm php8.4-mysql php8.4-curl php8.4-gd \
php8.4-mbstring php8.4-xml php8.4-zip \
curl
mariadb-server-core is the server binary package. mariadb-server is a meta-package that additionally depends on galera-4, perl, rsync, and socat, which are Galera cluster tools that a single-site server does not need. Installing mariadb-server-core directly avoids all of them.
The php8.4-opcache and php8.4-readline packages are pulled in as dependencies. Both are disabled from the FPM process in Stage 3.
Post-install cleanup. Nginx's post-install script attempts to start the service, which fails because IPv6 is disabled at the kernel and the default configuration includes listen [::]:80. Fix the default configuration and complete the package setup, then remove man-db which the install brought back as a trigger dependency:
sed -i "s/listen \[::\]:80 default_server;//" /etc/nginx/sites-available/default
dpkg --configure -a
apt-get purge -y man-db && apt-get autoremove -y --purge && apt-get clean
Stage 2: Configure MariaDB 10.11
The default MariaDB configuration reserves memory as if the server has gigabytes available. Three settings must be addressed:
innodb_buffer_pool_sizedefaults to 128MB. For a single low-traffic WordPress site this can be set to the minimum: 6MB, which is the smallest value the InnoDB engine accepts for the default 16KB page size.aria_pagecache_buffer_sizedefaults to 128MB. The Aria engine is used only by MariaDB's internalmysqlschema tables. 4MB is sufficient.key_buffer_sizedefaults to 128MB. This is the MyISAM key cache. WordPress has zero MyISAM tables. It can be set to zero.
cat > /etc/mysql/mariadb.conf.d/99-minvps.cnf << 'EOF'
[mysqld]
# Disable performance schema
performance_schema = OFF
# InnoDB -- default is 128MB, 6M is minimum for 16KB page size
innodb_buffer_pool_size = 6M
innodb_log_buffer_size = 2M
innodb_flush_log_at_trx_commit = 2
innodb_buffer_pool_dump_at_shutdown = OFF
innodb_buffer_pool_load_at_startup = OFF
# InnoDB threads -- write minimum is 2, read and purge can be 1
innodb_read_io_threads = 1
innodb_write_io_threads = 2
innodb_purge_threads = 1
# InnoDB open files -- we have 16 WordPress tables
innodb_open_files = 32
# Aria -- used by mysql schema internally; all WordPress tables are InnoDB
# Default 128MB; mysql schema Aria data totals ~3MB
aria_pagecache_buffer_size = 4M
# MyISAM key cache -- zero MyISAM tables in WordPress
key_buffer_size = 0
# Per-session sort/join buffers -- only allocated during queries, not at idle
sort_buffer_size = 256K
join_buffer_size = 128K
read_rnd_buffer_size = 128K
# No DNS lookups -- DB only accepts localhost connections
skip_name_resolve = ON
host_cache_size = 0
# Connection and table limits
max_connections = 10
max_allowed_packet = 16M
table_open_cache = 64
table_definition_cache = 400
thread_cache_size = 2
# Temp tables
tmp_table_size = 8M
max_heap_table_size = 8M
# No binary logging on a single server
skip_log_bin
# Unix socket only
bind-address = 127.0.0.1
[client]
default-character-set = utf8mb4
EOF
innodb_buffer_pool_dump_at_shutdown = OFFandinnodb_buffer_pool_load_at_startup = OFFdisable the buffer pool state file. With a 6MB pool, saving and reloading it on every restart is pointless I/O.innodb_write_io_threadshas a hard minimum of 2 in MariaDB 10.11. Read and purge threads can be reduced to 1.skip_log_bindisables binary logging. Binary logs are used for replication and point-in-time recovery. A single-server WordPress instance has no replicas, and recovery is handled by scheduled backups.performance_schema = OFFdisables the performance monitoring tables. They serve no purpose on a production single-site server.
mariadb-server-core does not include a systemd service file, nor does it create the mysql system user or the data directory. Do all three before starting the service:
groupadd -r mysql
useradd -r -g mysql -d /var/lib/mysql -s /usr/sbin/nologin mysql
mkdir -p /var/lib/mysql
chown mysql:mysql /var/lib/mysql
mariadb-install-db --user=mysql --datadir=/var/lib/mysql
cat > /etc/systemd/system/mariadb.service << 'EOF'
[Unit]
Description=MariaDB 10.11 LTS Database Server
After=network.target
[Service]
Type=notify
User=mysql
Group=mysql
ExecStart=/usr/sbin/mariadbd --defaults-file=/etc/mysql/mariadb.conf.d/99-minvps.cnf
Restart=on-failure
RuntimeDirectory=mysqld
RuntimeDirectoryMode=0755
TimeoutSec=300
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable mariadb
Start MariaDB and create the WordPress database:
systemctl restart mariadb
DB_PASS=$(openssl rand -base64 18)
echo "DB password: $DB_PASS"
mysql -e "CREATE DATABASE wordpress CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
mysql -e "CREATE USER 'wordpress'@'localhost' IDENTIFIED BY '$DB_PASS';"
mysql -e "GRANT ALL PRIVILEGES ON wordpress.* TO 'wordpress'@'localhost';"
mysql -e "FLUSH PRIVILEGES;"
Save the generated password. It is needed in Stage 5.
Stage 3: Configure PHP-FPM
Pool configuration. The default FPM pool uses pm = dynamic, which pre-forks worker processes at startup. For a low-traffic site, pm = ondemand is better: it spawns a worker only when a request arrives and kills it after 10 seconds of inactivity. At idle, only the master process remains.
Edit /etc/php/8.4/fpm/pool.d/www.conf and set:
pm = ondemand
pm.max_children = 1
pm.process_idle_timeout = 10s
pm.max_requests = 500
Timezone. PHP 8.4 rejects the bare string UTC as a timezone identifier. Use the canonical name:
sed -i "s|;date.timezone =|date.timezone = Etc/UTC|" \
/etc/php/8.4/fpm/php.ini \
/etc/php/8.4/cli/php.ini
Disable unused extensions from FPM. The default PHP install loads 32 extensions in the FPM process. WordPress needs 18 of them. The -s fpm flag disables extensions only in FPM, leaving the CLI unaffected:
phpdismod -s fpm \
ffi shmop sysvmsg sysvsem sysvshm \
sockets calendar xsl gettext readline \
phar ftp tokenizer
Opcache is a Zend extension and is not handled by phpdismod. Remove its FPM configuration file directly:
rm /etc/php/8.4/fpm/conf.d/10-opcache.ini
The 18 extensions remaining in FPM: ctype, curl, dom, exif, fileinfo, gd, iconv, mbstring, mysqli, mysqlnd, pdo, pdo_mysql, posix, simplexml, xml, xmlreader, xmlwriter, zip. This is the minimum set for a fully functional WordPress install including image uploads and resize.
Restart PHP-FPM:
systemctl restart php8.4-fpm
Stage 4: Configure Nginx
Debian 13's default Nginx configuration tries to listen on [::]:80. IPv6 was disabled at the kernel in the minimisation guide. Replace the configuration entirely:
cat > /etc/nginx/nginx.conf << 'EOF'
user www-data;
worker_processes 1;
pid /run/nginx.pid;
events {
worker_connections 512;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
sendfile on; tcp_nopush on; tcp_nodelay on;
keepalive_timeout 15;
server_tokens off;
access_log off;
error_log /var/log/nginx/error.log warn;
gzip on;
gzip_types text/plain text/css application/javascript application/json text/xml application/xml application/rss+xml;
gzip_min_length 1000;
fastcgi_cache_path /tmp/nginx-cache levels=1:2 keys_zone=WORDPRESS:10m inactive=60m max_size=50m;
fastcgi_cache_key "$scheme$request_method$host$request_uri";
include /etc/nginx/conf.d/*.conf;
}
EOF
The FastCGI cache lives in /tmp/nginx-cache on tmpfs. This directory disappears at reboot. Create a tmpfiles rule to recreate it on boot:
echo "d /tmp/nginx-cache 0755 www-data www-data -" > /etc/tmpfiles.d/nginx-cache.conf
mkdir -p /tmp/nginx-cache
chown www-data:www-data /tmp/nginx-cache
Create the site configuration. Replace YOUR_DOMAIN with your domain name and YOUR_IP with the IP address you will administer wp-admin from:
cat > /etc/nginx/conf.d/YOUR_DOMAIN.conf << 'EOF'
set_real_ip_from 10.X.X.X/24;
real_ip_header X-Forwarded-For;
real_ip_recursive on;
server {
listen 80;
server_name YOUR_DOMAIN;
root /var/www/YOUR_DOMAIN;
index index.php;
location = /xmlrpc.php { deny all; }
location ~ ^/(wp-admin|wp-login\.php) {
allow YOUR_IP_HERE;
deny all;
try_files $uri $uri/ /index.php?$args;
location ~ \.php$ {
include fastcgi_params;
fastcgi_pass unix:/run/php/php8.4-fpm.sock;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_cache_bypass 1;
fastcgi_no_cache 1;
}
}
set $skip_cache 0;
if ($request_method = POST) { set $skip_cache 1; }
if ($query_string != "") { set $skip_cache 1; }
if ($http_cookie ~* "comment_author|wordpress_[a-f0-9]+|wp-postpass|wordpress_no_cache|wordpress_logged_in") { set $skip_cache 1; }
location / { try_files $uri $uri/ /index.php?$args; }
location ~ \.php$ {
include fastcgi_params;
fastcgi_pass unix:/run/php/php8.4-fpm.sock;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_cache WORDPRESS;
fastcgi_cache_valid 200 60m;
fastcgi_cache_bypass $skip_cache;
fastcgi_no_cache $skip_cache;
fastcgi_cache_use_stale error timeout updating http_500 http_503;
add_header X-Cache $upstream_cache_status;
}
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
expires 30d;
add_header Cache-Control "public, immutable";
log_not_found off;
}
}
EOF
The set_real_ip_from and real_ip_header directives are for TierHive instances where all external traffic arrives through HAProxy from the 10.0.0.0/8 range. They replace the HAProxy-internal source IP with the real client IP before the allow/deny rules are evaluated. On a server with a direct public IP, remove those three lines.
The wp-admin and wp-login.php location block restricts access to a single IP before any PHP executes. The X-Cache header exposes cache status to the client (HIT or MISS). Remove it if you do not want this visible in response headers.
Remove the default Nginx site configuration and test:
rm -f /etc/nginx/sites-enabled/default /etc/nginx/conf.d/default.conf
nginx -t && systemctl reload nginx
Stage 5: Install WordPress
Create the web root:
mkdir -p /var/www/YOUR_DOMAIN
chown www-data:www-data /var/www/YOUR_DOMAIN
Install WP-CLI. WP-CLI is the WordPress command-line management tool. Dropbear does not support SFTP, so download directly to the VPS:
curl -sL https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar \
-o /usr/local/bin/wp && chmod +x /usr/local/bin/wp
Download WordPress and create wp-config.php. Run as www-data so that all files are owned by the web server user. This causes WordPress to detect FS_METHOD = direct, meaning it can update itself and install plugins without FTP credentials. sudo was removed so either add it back or stick with su; use su to switch user from root:
su -s /bin/bash -c "wp core download --path=/var/www/YOUR_DOMAIN" www-data
su -s /bin/bash -c "wp config create --path=/var/www/YOUR_DOMAIN --dbname=wordpress --dbuser=wordpress --dbpass=YOUR_DB_PASSWORD --dbhost=localhost --extra-php" www-data << 'PHPEOF'
define( 'DISABLE_WP_CRON', true );
define( 'DISALLOW_FILE_EDIT', true );
define( 'WP_POST_REVISIONS', 3 );
if ( isset( $_SERVER['HTTP_X_FORWARDED_PROTO'] ) && 'https' === $_SERVER['HTTP_X_FORWARDED_PROTO'] ) {
$_SERVER['HTTPS'] = 'on';
}
PHPEOF
DISABLE_WP_CRONstops WordPress from triggering cron jobs on page load. A system cron job replaces it below.DISALLOW_FILE_EDITremoves the theme and plugin editor from wp-admin, reducing the attack surface if credentials are compromised.WP_POST_REVISIONS = 3caps the revision history stored per post, keeping the database smaller.- The
HTTP_X_FORWARDED_PROTOblock tells WordPress it is being served over HTTPS even though Nginx receives plain HTTP from the upstream proxy. Without it, WordPress generateshttp://URLs and the browser enters an infinite redirect loop. This applies when SSL terminates upstream: HAProxy, Cloudflare, or a load balancer. Remove it if Nginx handles TLS directly.
Install WordPress:
su -s /bin/bash -c 'wp core install --path=/var/www/YOUR_DOMAIN --url=https://YOUR_DOMAIN --title="YOUR_SITE_TITLE" --admin_user=YOUR_ADMIN_USER --admin_password=YOUR_ADMIN_PASSWORD --admin_email=YOUR_EMAIL --skip-email' www-data
Replace WP-Cron with a system cron job. WordPress's built-in cron fires on page load, which adds latency and fails entirely if the site has no traffic. A system job runs on a fixed schedule regardless:
cat > /etc/cron.d/wordpress << 'EOF'
*/5 * * * * www-data php /usr/local/bin/wp cron event run --due-now --path=/var/www/YOUR_DOMAIN --quiet 2>/dev/null
EOF
Reboot
reboot
After
total used free shared buff/cache available
Mem: 213 84 32 0 104 128
Swap: 0 0 0
Filesystem Size Used Avail Use% Mounted on
/dev/vda1 2.8G 800M 1.9G 31% /
RAM up from 38MB to 84MB. Disk up from 275MB to 800MB.
What Is Still Running
At idle with no active requests, the FPM pool has no workers. Workers spawn on demand and exit after 10 seconds of inactivity.
| Process | RSS | PSS |
|---|---|---|
| mariadbd | 57MB | 52MB |
| php-fpm master (no workers) | 30MB | 25MB |
| nginx (master + worker + cache_mgr) | 11MB | -- |
| systemd | 14MB | -- |
| systemd-udevd | 9MB | -- |
| dbus, dropbear, busybox syslogd, getty | 12MB | -- |
RSS is the process's resident set. PSS (proportional set size) credits each process only its share of shared library mappings and is the more accurate measure of unique memory use.
Under load with one active PHP worker, total used RAM is approximately 112MB. Cached requests are served by Nginx without spawning a worker.
MariaDB's 57MB RSS is close to the practical minimum for 10.11 with InnoDB. innodb_buffer_pool_size = 6M is the smallest value the engine accepts for the default 16KB page size.
What the tuning saves
The three MariaDB buffer defaults (innodb_buffer_pool_size, aria_pagecache_buffer_size, and key_buffer_size) reserve 384MB of buffer space in a default install. None of it is used by a single WordPress site. The full set of changes applied:
| Change | Saving |
|---|---|
| MariaDB 10.11 LTS instead of Debian's 11.8 | ~13MB |
innodb_buffer_pool_size 128MB → 6MB |
~30MB |
aria_pagecache_buffer_size 128MB → 4MB |
~15MB |
key_buffer_size 128MB → 0 |
~5MB |
| InnoDB IO threads 4 → 2 (write), 1 (read/purge) | ~2MB |
innodb_buffer_pool_load_at_startup = OFF |
marginal |
| Removed opcache shared memory | ~12MB |
| Disabled 13 FPM extensions via phpdismod | ~2MB |
I did run this on 128mb ram for a few days, it ran ok, but if you build aritifical load it soon starts to fall over, so on that basis I recommend 256mb.
I am also aware you can run this on sqlite and then probably 128mb is fine, i decided against that, apart from the concurrency issues with sqlite, there are also lots of reported plugin issues, so its not technically supported as a "standard full WordPress install" but you can probably save about 40 - 45mb ram if you decide to use SQLite instead then 128mb is fine.
Example site running this: https://pop2.me
Thats the end of the guides for a while, might do some micro howto's but these full fat ones take to long for me right now and I tend to get obsessed with trying ot shave KB's of ram off.
Comments
Fantastic !
Coming weekend will try this out and I believe with ClassicPress ( no gutenburg crap) and images via cdn and wp-cli, (ie no to minimal need to log on to web dashboard) the site should purr along with aplomb
blog | exploring visually |
That's one way to take a nice small footprint and shit all over it.
That's almost my LAMP stack verbatim, down to PHP-FPM.
My pronouns are like/subscribe.
Must be good then.
TierHive - Hourly VPS - NAT Native - /24 per customer - Lab in the cloud - Free to try. | I am Anthony Smith
FREE tokens when you sign up, try before you buy. | Join us on Reddit
2006 was a good year. Why change?
I remember people going nuts over Caddy, and yeah, I just never bothered to follow. I use nginx as a reverse proxy and static server, but for actual real-work stuff, I'm still heavily reliant on Apache quirks and rewriting.
My pronouns are like/subscribe.
I have not used caddy ever, in fact I am still annoyed that lighttpd did not stick around.
TierHive - Hourly VPS - NAT Native - /24 per customer - Lab in the cloud - Free to try. | I am Anthony Smith
FREE tokens when you sign up, try before you buy. | Join us on Reddit
Hi Ant!
Seems https://www.lighttpd.net/ is still around.
What am I missing?
Thanks!
Tom
I hope everyone gets the servers they want!
I feel like I must have shifted into a parallel universe, I had to look this up haha, so about 2012 ISH best guess it became impossible to keep using it and I was forced to use nginx.
A lot of upstream things I used stopped supporting lighttpd.
From that point it felt like an abandoned project for years, looks like it got picked up again a few years later and I never noticed.
I guess I just had my head in the sand...
Oh well I better get reading
TierHive - Hourly VPS - NAT Native - /24 per customer - Lab in the cloud - Free to try. | I am Anthony Smith
FREE tokens when you sign up, try before you buy. | Join us on Reddit