A deep dive into building a secure, fast, easy to manage WordPress hosting stack


WordPress is easy to install. But to make it run smoothly, it needs a production-ready WordPress hosting stack around it.

A good stack does several jobs at once:

It serve pages quickly.

It keeps WordPress, plugins, PHP, MariaDB, and the operating system updated.

It blocks common attacks without breaking legitimate admin work.

It gives customers useful access without giving them the whole server.

It takes backups in a way that makes restores easy.

This article walks through the configurations we use for Woop! Host, our managed WordPress hosting service. The exact commands will vary between environments, but the design choices are the useful part. The goal is to show the pieces you should think about when building a WordPress hosting stack.

The stack

Our standard stack is:

  • Debian (currently 13)
  • NGINX
  • Varnish
  • PHP-FPM
  • MariaDB
  • Redis
  • WordPress
  • WP-CLI
  • Let’s Encrypt
  • Fail2ban
  • Monit
  • rdiff-backup
  • A jailed wpuser account for SFTP, SSH, WP-CLI, and database access
  • A small custom WordPress plugin for hosting defaults, mail tracing, SMTP configuration, and update email noise reduction

The traffic path looks like this:

Internet HTTPS :443
  -> NGINX TLS endpoint
  -> Varnish on localhost port 80
  -> NGINX backend on 127.0.0.1:8080
  -> PHP-FPM
  -> WordPress
  -> MariaDB / Redis

Public HTTP traffic on port 80 goes to Varnish. HTTPS traffic terminates at NGINX first, then passes through Varnish to the backend NGINX/PHP layer.

There is a separation between Varnish and NGINX. NGINX handles TLS, headers, static files, PHP routing, and hard security denies. Varnish handles full-page caching and cache purging. PHP-FPM runs WordPress. Redis handles object caching. MariaDB stores the site data.

1. Start with a small attack surface

WordPress hosting servers should only expose the services that need to be public.

For this stack, the intended public services are:

22/tcp   SSH
80/tcp   Varnish HTTP
443/tcp  NGINX HTTPS

Everything else should bind locally where practical:

127.0.0.1:3306  MariaDB
127.0.0.1:6379  Redis
127.0.0.1:25    Postfix local mail
127.0.0.1:8080  NGINX backend
127.0.0.1:6082  Varnish admin

If a service does not need direct internet access, it should not listen on the public interface.

Redis is a good example. WordPress benefits from Redis object caching, but Redis should not be internet reachable. Bind it to 127.0.0.1, and keep protected mode enabled.

2. Use NGINX as the TLS and PHP control point

NGINX does several jobs in this stack.

It terminates HTTPS, serves static assets, proxies dynamic traffic to Varnish, and sends PHP requests to PHP-FPM.

For TLS, use modern protocol defaults:

ssl_protocols TLSv1.2 TLSv1.3;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
ssl_session_tickets off;
ssl_stapling on;
ssl_stapling_verify on;

Add the usual security headers:

add_header Strict-Transport-Security "max-age=16416000" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "origin-when-cross-origin" always;

Be careful with HSTS. Do not use includeSubDomains unless you control every subdomain and know every subdomain supports HTTPS.

The backend NGINX server listens on 127.0.0.1:8080. That backend handles the WordPress document root:

root /var/www/wordpress;
index index.php index.html;

It also blocks files that should never be web-accessible:

location = /wp-config.php {
  deny all;
}

location ~* /(?:uploads|files|wp-content|wp-includes)/.*\.php$ {
  deny all;
}

location ~* /\. {
  deny all;
}

location ~* ^/(?:readme\.html|license\.txt|wp-config\.php)$ {
  deny all;
}

Those denies are important. They stop direct access to config files, dotfiles, and uploaded PHP files. Uploaded PHP execution is a common WordPress compromise path.

We also disable XML-RPC at NGINX:

location = /xmlrpc.php {
  access_log off;
  return 444;
}

That cuts off a noisy attack surface. Some older apps and plugins still use XML-RPC, so this should be a deliberate hosting policy. For most modern brochure sites and business sites, disabling it is the right default.

3. Give admin requests more room without giving every request more room

Most frontend page requests should be fast. Admin work can be slow.

Image uploads, page-builder saves, REST API media operations, WooCommerce admin screens, and import tasks can need more memory and longer timeouts.

Instead of raising limits for everything, use an NGINX map for selected paths:

map $request_uri $php_admin_value {
  default                       "";
  ~^/wp-admin/                  "memory_limit=512M\nmax_execution_time=1200";
  ~^/wp-login\.php$             "memory_limit=512M\nmax_execution_time=1200";
  ~^/wp-admin/admin-ajax\.php$  "memory_limit=512M\nmax_execution_time=1200";
}

Then pass it into PHP-FPM:

fastcgi_param PHP_VALUE $php_admin_value;

This gives WordPress admins enough headroom without making normal page views more expensive.

Some practical base PHP settings include:

upload_max_filesize = 128M
post_max_size = 256M
memory_limit = 256M
max_execution_time = 600
max_input_time = 600
max_input_vars = 5000
cgi.fix_pathinfo = 0

cgi.fix_pathinfo=0 is a hardening default. It reduces the risk of PHP interpreting unexpected paths as executable scripts.

4. Use short NGINX microcaching to absorb bursts

Varnish is the main page cache, but NGINX also has FastCGI microcaching configured:

fastcgi_cache_path /var/cache/nginx levels=1:2 keys_zone=microcache:100m inactive=60m max_size=2g use_temp_path=off;
fastcgi_cache_key "$scheme$request_method$host$request_uri";
fastcgi_cache microcache;
fastcgi_cache_lock on;
fastcgi_cache_lock_timeout 3s;
fastcgi_cache_valid 200 301 302 10s;
fastcgi_cache_use_stale updating error timeout invalid_header http_500 http_503;

The idea is not to cache pages for hours. The idea is to smooth out bursts.

A 10-second cache can stop 200 simultaneous visitors from all hitting PHP for the same uncached page. fastcgi_cache_lock helps prevent a thundering herd by letting one request populate the cache while others wait or pass through. NGINX documents fastcgi_cache_lock_timeout as the time after which a waiting request passes to FastCGI without caching the response.

Do not cache everything. The stack bypasses cache for:

  • POST requests
  • Query-string requests
  • Logged-in users
  • Recent commenters
  • wp-admin
  • wp-login.php
  • wp-cron.php
  • feeds
  • sitemaps
  • Adminer
  • WooCommerce cart and checkout style paths

The principle here is to cache anonymous frontend pages but not to cache personalised, authenticated, cart, checkout, admin, or AJAX behaviour.

5. Put Varnish in front of WordPress, but keep the rules conservative

Varnish is the full-page cache layer.

The backend points to NGINX on localhost:

backend default {
  .host = "127.0.0.1";
  .port = "8080";
  .first_byte_timeout = 600s;
  .between_bytes_timeout = 600s;
}

Varnish normalises requests before caching:

set req.url = std.querysort(req.url);
unset req.http.proxy;

It also strips tracking query parameters and normalises Accept-Encoding. That prevents cache fragmentation where the same page gets stored many times because of analytics parameters or minor header differences.

Varnish must pass through requests that should never be cached:

if (req.method != "GET" && req.method != "HEAD") {
  return (pass);
}

if (req.http.Authorization) {
  return (pass);
}

if (req.url ~ "wp-admin|wp-login|wp-cron|wp-json|adminer") {
  return (pass);
}

For WooCommerce, it also passes cart, checkout, account, session, and add-to-cart traffic.

The stack uses a PURGE flow with a localhost-only ACL:

acl purge {
  "localhost";
  "127.0.0.1";
  "::1";
}

That allows WordPress to purge cache when content changes, while preventing public purge requests. Varnish’s own WordPress guidance makes the same point: Varnish can cache the content, but WordPress needs to trigger cache invalidation when posts and pages change.

A WordPress plugin such as Proxy Cache Purge, or the varnish-http-purge plugin used in this stack, sends purge requests when content changes. The plugin’s own description says it deletes cached page/post data when content is modified.

The Varnish backend response logic also sets grace:

set beresp.grace = 6h;

Grace lets Varnish keep serving stale content for a while if the backend is slow or temporarily unavailable. That is useful for keeping a public site online during brief PHP or database issues.

6. Add Redis object caching

Full-page caching helps anonymous visitors. Redis helps WordPress itself.

WordPress still has to run admin screens, cron tasks, page builder saves, WooCommerce operations, and uncached requests. Redis reduces repeated database work by storing object cache data in memory.

The stack installs and activates the Redis cache plugin, then enables it with WP-CLI:

wp plugin install redis-cache --activate
wp redis enable

Set a Redis memory policy to cap memory usage and select an eviction policy:

maxmemory 256mb
maxmemory-policy allkeys-lru

7. Tune PHP-FPM for the VM size

The PHP-FPM pool runs as the site user rather than www-data:

user = wpuser
group = wpuser
listen.owner = wpuser
listen.group = wpuser

The stack also adjusts pool capacity:

pm.start_servers = 5
pm.min_spare_servers = 3
pm.max_spare_servers = 10
pm.max_children = 20
pm.status_path = /status

This is a reasonable starting point for a small managed WordPress VM, but pm.max_children should be sized from memory, not copied blindly.

A practical method:

  1. Measure average PHP-FPM child memory under real traffic.
  2. Reserve memory for MariaDB, Redis, Varnish, NGINX, the OS, and backups.
  3. Divide the remaining memory by PHP child size.
  4. Set pm.max_children below that number.

If PHP children average 80 MB and you can safely spare 1.2 GB for PHP, then 15 children is safer than 30. Too many PHP children can make the server slower by pushing it into memory pressure.

8. Tune MariaDB for WordPress

The stack sets several MariaDB options:

innodb_file_per_table=1
innodb_flush_method=O_DIRECT
slow_query_log=1
long_query_time=2

Those are sensible defaults.

innodb_file_per_table=1 keeps tables in separate files, which helps with space management and some backup/restore workflows.

O_DIRECT reduces double-buffering between InnoDB and the operating system page cache.

The slow query log is useful on WordPress because performance problems often come from plugins, theme queries, or WooCommerce tables. A long_query_time of 2 seconds is a practical starting point.

The stack also calculates an InnoDB buffer pool automatically:

25% of RAM
minimum 256 MB
maximum 4096 MB

That is a safe general-purpose rule for small VMs. Larger database-heavy sites may need more.

9. Use WP-CLI for repeatable WordPress management

WP-CLI is the control surface for the stack.

It installs WordPress, creates wp-config.php, installs plugins, updates core, updates themes, updates plugins, enables Redis, configures permalinks, and manages scheduled tasks.

The stack sets useful WordPress defaults:

wp option update permalink_structure '/%year%/%monthnum%/%postname%/'
wp option update comment_registration 1
wp option update default_comment_status closed

It also sets memory constants if they are missing:

define('WP_MEMORY_LIMIT', '256M');
define('WP_MAX_MEMORY_LIMIT', '512M');

And it disables visitor-triggered WP-Cron:

define('DISABLE_WP_CRON', true);

Then it runs cron from the system every five minutes:

*/5 * * * * flock -n /tmp/wp-cron-example.lock -c "/usr/bin/php /var/www/wordpress/wp-cron.php"

This change is to avoid having to rely on frontend traffic to trigger scheduled tasks. Low-traffic sites still run scheduled jobs. Busy sites do not trigger cron unpredictably during page requests.

10. Choose plugins that support your hosting operations

This stack installs a small set of default plugins:

  • page caching
  • Varnish purge integration
  • Redis object caching
  • image optimisation
  • server stats
  • history/audit visibility
  • sitemap generation
  • analytics
  • a stack-specific plugin

The stack-specific wphost plugin is especially useful. It does three jobs.

First, it suppresses routine update emails while preserving important failures. That avoids noisy inboxes when dozens or hundreds of sites update daily.

Second, it logs WordPress mail attempts, successes, and failures to syslog. The log record includes the site host, recipient list, subject, request context, and a trimmed backtrace into wp-content.

That is useful when a compromised plugin, vulnerable form, or misconfigured site starts sending mail. You can answer the question: “what script sent this?”

Third, it supports SMTP settings from wp-config.php constants:

define('SITE_SMTP_HOST', 'smtp.example.com');
define('SITE_SMTP_PORT', 587);
define('SITE_SMTP_USER', 'wordpress@example.com');
define('SITE_SMTP_PASS', 'secret');
define('SITE_SMTP_USER_DISPLAYNAME', 'Example Website');

That lets the hosting stack route WordPress mail through a customer-approved SMTP host or a managed mail platform, rather than relying on direct PHP mail delivery from the web VM. If you have properly secured your email domain with DKIM/DMARC/etc DNS settings, this option will let you use your compliant SMTP servers.

11. Enable updates, but make rollback easy

WordPress security depends heavily on updates. The WordPress security handbook says the most important security step is keeping WordPress core, plugins, and themes up to date.

The stack uses two layers of updates.

At the operating system level:

APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";

At the WordPress level, daily cron runs:

wp core update
wp plugin update --all
wp theme update --all

On first install, plugin auto-updates are also enabled for installed plugins.

This update posture can be appropriate for many managed WordPress sites, especially small business sites that would otherwise sit unpatched for months. WordPress supports automatic background updates for core, plugin, theme, and translation updates.

But updates sometimes have consequences. They can break fragile plugins, old themes, WooCommerce customisations, and page-builder sites.

The practical hosting policy should be:

  • Auto-update low-risk sites by default.
  • Take backups before updates.
  • Log exactly what changed.
  • Allow high-risk sites to opt into a slower update track.
  • Treat WooCommerce and custom-code sites differently from brochure sites.
  • Keep emergency rollback simple.

To de-risk updates, the stack needs to have good backup and restore options.

12. Back up files and databases separately

A useful WordPress backup needs both files and database.

The database contains posts, pages, users, orders, settings, plugin data, and many custom fields.

The files contain WordPress core, themes, plugins, uploads, custom code, and media.

The backup script uses mysqldump with good defaults:

mysqldump \
  --single-transaction \
  --quick \
  --routines \
  --triggers \
  --events \
  --default-character-set=utf8mb4

--single-transaction is the key option for InnoDB sites. It allows a consistent dump without locking every table for the full dump.

The script stages the SQL dump, then backs up both files and database dumps with rdiff-backup:

/backup/rdiff-wordpress/files
/backup/rdiff-wordpress/db

It excludes generated or disposable paths:

wp-content/cache
wp-content/upgrade
wp-content/uploads/cache
wp-content/uploads/wc-logs
wp-content/debug.log
.well-known/acme-challenge
error_log
logs
tmp

It also ignores a WP Rocket generated CSS table:

--ignore-table=${DB_NAME}.wpr_rucss_resources

This way we do not waste backup space on generated cache data that can be rebuilt.

Rdiff-backup is a useful tool. It will have the most recent backup of the site. Then it has a set of ‘patches’ – one per backup – that lets you step back in time to previous backups. To complete a restore rdiff-backup takes the latest file and applies the patches – going back in time, backup by backup – until it reaches the point in time before the specified restore date.

After each backup the backup size gets larger. So the stack sets a retention policy, like:

RETENTION_DAYS=30
MIN_RETENTION_DAYS=10
MAX_REPO_BYTES=10 GiB

Meaning: keeps normal history for 30 days, but shrink history if the backup repository exceeds the size cap, while preserving at least 10 days.

That gives you a sane local rollback story:

  • restore a deleted upload
  • roll back a plugin update
  • recover from a bad content edit
  • recover from many common compromises
  • compare current files against earlier versions

13. Automate Let’s Encrypt only when the domain points to the VM

Certificate automation can get messy on managed hosting because the domain names that point to a site can change.

This stack permits multiple domains to point to the WordPress site. It handles that by regularly checking whether each configured domain appears to point to the VM before requesting a certificate.

Only valid domains are added to the certificate request.

That avoids issuing certificates for domains that are not live yet. It also allows aliases to be added as they come online.

The stack switches these stable symlinks:

/etc/pki/woop/links/fullchain.pem
/etc/pki/woop/links/privkey.pem

NGINX always points to those paths. Initially these symlinks point to a self signed certificate. When a Let’s Encrypt certificate is created the stack switches that symlink to the production certificate.

Using the symlink lets the NGINX certificate config remain unchanged when switching from self-signed to LE certificates, and between different versions of the LE certificates.

14. Give customers useful access without giving them root

Customers and developers often need access to:

  • upload files
  • edit theme/plugin files
  • run WP-CLI
  • import/export database data
  • tunnel to MariaDB
  • inspect logs

Rather than giving end users unfettered root access, this stack creates a wpuser account and uses Jailkit to provide a chrooted environment under /var/sftp.

It bind-mounts useful paths into the jail:

/var/www/wordpress -> /var/sftp/var/www/wordpress
/home/wpuser       -> /var/sftp/home/wpuser
/run/mysqld        -> /var/sftp/run/mysqld
/var/log/nginx     -> /var/sftp/var/log/nginx

The jail includes selected tools:

wp
php
mysql
mysqldump
rsync
scp
zip
unzip
diff
editors
basic shell tools

SSH forwarding is restricted:

AllowTcpForwarding yes
PermitOpen localhost:3306
PermitTTY yes
PermitTunnel no
AllowStreamLocalForwarding no

That lets a developer tunnel to MariaDB without opening MariaDB publicly:

ssh -N -L 3307:localhost:3306 wpuser@example.com
mysql -h 127.0.0.1 -P 3307 -u dbuser -p dbname

With the wpuser in place developers get practical tools. The host keeps privileged root access private. Database access stays local or tunneled.

15. Monitor services and disk pressure

A managed stack should detect basic failures before customers do.

This stack uses Monit for service and resource checks:

nginx
varnish
mysql
root filesystem
load average
memory usage
inode usage

It also installs an hourly disk check that notifies the Woop! Host service if available disk space, or free memory, drops too low. That lets the RimuHosting VM-hosting platform resize storage before the site falls over.

The important checks are simple:

  • Is the web server alive?
  • Is Varnish alive?
  • Is MariaDB alive?
  • Is disk space low?
  • Are inodes exhausted?
  • Is memory pressure severe?
  • Did backups complete?
  • Is the certificate valid?
  • Are updates failing?
  • Is the site sending suspicious mail?

16. Log the things you will need during an incident

Good logs answer operational questions.

For WordPress hosting, the useful questions are:

  • Which IP hit wp-login.php repeatedly?
  • Which script sent this email?
  • Which plugin triggered a fatal error?
  • Which URL is slow?
  • Which requests bypass cache?
  • Which pages are cache hits or misses?
  • Did a backup finish?
  • Did an update fail?
  • Did Let’s Encrypt renew?
  • Did disk space cross a threshold?

The stack adds real-IP logging for NGINX, including the original forwarded IP chain and upstream response time.

It also exposes cache status headers:

add_header X-Cache $upstream_cache_status;

And Varnish adds:

X-Cache: HIT / MISS
X-Cacheable: reason

Those headers are useful during debugging. They make it easy to see whether a request was cached, bypassed, or passed through because of cookies, admin paths, POST, auth headers, or dynamic WooCommerce behaviour.

WordPress debug logs are rotated:

daily
rotate 3
maxsize 10M
create 0640 wpuser wpuser

That prevents wp-content/debug.log from growing until the disk fills.

The stack also defaults to installing and activating the simple-history WordPress plugin which tracks key changes like content edits, user logins, plugin updates, security events and lets WordPress administrators view those events.

17. Adminer for database access

This stack symlinks Adminer into the WordPress tree under /var/www/wordpress/db.

That is convenient for support. Particularly during site migrations requiring database imports/exports. It means database management is available through the site on a web page. In addition to the SSH-tunnelled mysql CLI access mentioned earlier.

The result

A good managed WordPress stack sets up a layered environment to support your WordPress operation.

Security comes from:

  • small public attack surface
  • modern TLS
  • blocked sensitive files
  • no PHP execution in uploads
  • XML-RPC disabled by default
  • local-only Redis and MariaDB
  • jailed customer access
  • Fail2ban for noisy login attacks
  • regular updates
  • useful logs
  • fast restores

Performance also comes from:

  • Varnish full-page caching
  • NGINX microcaching
  • Redis object caching
  • PHP-FPM sizing
  • MariaDB buffer tuning
  • static asset caching
  • cron moved out of page requests
  • query logging for slow plugin behaviour

Simpler hands-off operations are supported by:

  • WP-CLI for repeatability
  • Let’s Encrypt scripts that only issue certificates for live domains
  • daily updates
  • local rollback backups
  • off-server backup replication
  • Monit and disk checks
  • mail tracing

That goal of a good WordPress hosting stack is not just a WordPress install. But to create an environment that is self monitoring and self updating. And an environment that gives you the tools you need to easily manage your sites.

Want this handled for you?

Building a WordPress stack like this takes time. Keeping it secure, fast, backed up, monitored, and updated takes ongoing work.

That is what Woop! Host is built for.

We run managed WordPress hosting on a stack designed for real-world sites: NGINX, Varnish, Redis, PHP-FPM, MariaDB, automated backups, jailed developer access, SSL automation, monitoring, and regular updates.

You get the practical benefits without having to build and maintain the whole system yourself.

If you want a WordPress site that is fast, secure, backed up, and looked after by people who understand Linux hosting, talk to us.

Visit Woop! Host to learn more, or get in touch and we can help you work out the right setup for your site.


Leave a Reply