Debian 13, minimum ram and disk, 38mb - Guide - Part 2 (running wordpress)

AnthonySmithAnthonySmith 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_size defaults 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_size defaults to 128MB. The Aria engine is used only by MariaDB's internal mysql schema tables. 4MB is sufficient.
  • key_buffer_size defaults 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 = OFF and innodb_buffer_pool_load_at_startup = OFF disable the buffer pool state file. With a 6MB pool, saving and reloading it on every restart is pointless I/O. innodb_write_io_threads has a hard minimum of 2 in MariaDB 10.11. Read and purge threads can be reduced to 1.
  • skip_log_bin disables 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 = OFF disables 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_CRON stops WordPress from triggering cron jobs on page load. A system cron job replaces it below.
  • DISALLOW_FILE_EDIT removes the theme and plugin editor from wp-admin, reducing the attack surface if credentials are compromised.
  • WP_POST_REVISIONS = 3 caps the revision history stored per post, keeping the database smaller.
  • The HTTP_X_FORWARDED_PROTO block tells WordPress it is being served over HTTPS even though Nginx receives plain HTTP from the upstream proxy. Without it, WordPress generates http:// 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.

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

Thanked by (2)Not_Oles amj

Comments

  • vyasvyas OGSenpai

    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

    Thanked by (1)Not_Oles
  • WSSWSS OG

    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.

    Thanked by (1)Not_Oles

    My pronouns are like/subscribe.

  • AnthonySmithAnthonySmith ModeratorHosting ProviderOGSenpai

    @WSS said:
    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.

    Must be good then.

    Thanked by (1)Not_Oles

    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

  • WSSWSS OG

    @AnthonySmith said:

    @WSS said:
    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.

    Must be good then.

    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.

    Thanked by (1)Not_Oles

    My pronouns are like/subscribe.

Sign In or Register to comment.