We have been running LEMP stacks in production for the Ugandan sites we build — not as abstract theory, but as the actual infrastructure behind sweetcakesuganda.com (a static catalogue with WhatsApp orders, no backend) and hereinuganda.com (a travel platform with content guides and SEO-focused pages). These two patterns force us to think about the stack very differently. One site needs almost nothing from PHP or MySQL; the other relies on clean server-side rendering, fast queries, and reliable uptime for organic traffic. Between them we have learned what breaks, what scales poorly, and what actually saves a site at 2 a.m. when traffic spikes or a query hangs.
This is what we now do every time we deploy a LEMP site in production.
What our two patterns taught us about load
The sweetcakesuganda.com rebuild is a static catalogue: HTML, CSS, and JavaScript with no database connection and no backend. It uses nginx to serve pre-built files directly, and WhatsApp handles orders. We never touch PHP-FPM or MySQL for it. Yet we still run the full LEMP stack on the same server because hereinuganda.com — a travel guide platform with article content, affiliate links, and SEO-focused pages — depends on PHP for dynamic rendering and MySQL for content relationships.
Keeping both patterns on one server taught us a hard lesson: a misconfigured LEMP stack does not just slow one site; it threatens both. When PHP-FPM runs out of worker processes, nginx returns 502 errors for the travel guides. When MySQL slow queries pile up, the backup process locks tables and the static catalogue stops refreshing. We had to design our nginx and PHP-FPM configurations so the two sites share resources fairly, rather than letting one starve the other.
For hereinuganda.com, load times matter directly for search rankings. We measured the homepage at 1.2 seconds before tuning and 380 milliseconds after adding nginx caching and reducing PHP-FPM overhead. On a 1 GB VPS, that difference is not cosmetic — it is the gap between a reader finishing an article and bouncing back to Google.
Nginx patterns we rely on
Our nginx server blocks follow the same structure for both sites. We start with a lean upstream and fastcgi settings, then layer in site-specific rules.
For hereinuganda.com, the critical addition is a fastcgi cache that stores rendered pages for 60 seconds. The server block looks like this:
server {
listen 80;
server_name hereinuganda.com;
root /var/www/hereinuganda/public;
index index.php index.html;
location / {
try_files $uri $uri/ /index.php?$args;
}
location ~ \.php$ {
fastcgi_pass unix:/run/php/php8.2-fpm.sock;
fastcgi_index index.php;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_cache HEREINUGANDA;
fastcgi_cache_valid 200 60s;
fastcgi_cache_use_stale error timeout updating http_500;
}
}
We also set fastcgi_cache_use_stale so that if PHP-FPM is temporarily unavailable — say during a restart — nginx serves the cached page instead of a 502. That single line has prevented downtime during updates more times than we can count. For sweetcakesuganda.com, which has no PHP, the block is shorter: just static file serving with gzip compression enabled.
A pattern we adopted from production failures: always set proxy_connect_timeout and proxy_read_timeout explicitly, never rely on nginx defaults. When MySQL is under load, PHP scripts hang longer than the default 60 seconds, and nginx kills the connection with a 504. Raising the timeout to 90 seconds gives the database room to recover without the user seeing an error. It is not a fix — it is a buffer that prevents a temporary slowdown from becoming a visible failure.
PHP-FPM tuning: pools, workers, and memory
We run PHP 8.2-FPM with separate pools for each site. The pool for hereinuganda.com is the one we tune most aggressively. Our production settings are:
[hereinuganda]
user = www-data
listen = /run/php/php8.2-fpm.sock
listen.owner = www-data
listen.group = www-data
pm = dynamic
pm.max_children = 12
pm.start_servers = 4
pm.min_spare_servers = 2
pm.max_spare_servers = 6
pm.max_requests = 500
We settled on max_children = 12 after measuring memory usage: each PHP worker uses roughly 35 MB at peak, so 12 workers stays under 450 MB, leaving headroom for MySQL and nginx on the same 1 GB instance. Before this, we had max_children set to 25. It handled more concurrent connections — around 45 concurrent users — but crashed the server under sustained load because memory exhaustion triggered the OOM killer. Now we handle about 25 concurrent users reliably, which is more than enough for our traffic profile.
pm.max_requests = 500 is a defensive measure. PHP workers accumulate memory over time due to leaks in extensions and long-running scripts. Restarting a worker after 500 requests prevents slow memory growth from becoming a crash after 48 hours. We noticed this specifically after a plugin on hereinuganda.com started leaking 2 MB per request. Without the restart, the server would have failed by the third day.
We also keep pm = dynamic rather than ondemand. Dynamic maintains a warm pool of workers, which means the first request after a traffic lull does not wait for a new process to spawn. The difference is around 300 milliseconds for cold starts — small, but visible in page speed metrics.
MySQL slow query logs and the queries that break
Every production server we manage has the slow query log enabled:
slow_query_log = 1
slow_query_log_file = /var/log/mysql/slow.log
long_query_time = 2
log_queries_not_using_indexes = 1
We set long_query_time = 2 seconds, which catches anything that would frustrate a user. We also log queries that skip indexes, because missing indexes are the hidden cause of most slowdowns.
On hereinuganda.com, the worst offender was a guide listing query that joined three tables without an index on the category column. It ran in 6.8 seconds under load. After adding CREATE INDEX idx_category ON guides(category_id);, the same query dropped to 45 milliseconds. That is a 150x improvement from a single index. The slow log made it visible; without it, we would have blamed the server or the network.
We rotate the slow log weekly with a simple systemctl command:
systemctl restart mysql
mysql -e "SET GLOBAL slow_query_log = 'OFF'; FLUSH LOGS; SET GLOBAL slow_query_log = 'ON';"
Restarting MySQL resets the log file cleanly. We prefer this to mv and signal-based rotation because it avoids file descriptor issues with some monitoring agents.
Another pattern we adopted: we review the top 10 slowest queries every Monday morning. It takes 15 minutes and has prevented more downtime than any other habit. One week we found a query running 12 times per minute that scanned 40,000 rows. It turned out to be a missing LIMIT clause on an archive page. Fixing it reduced server load by 30 percent.
Monitoring and common failures
We do not use a complex monitoring stack. For these sites, we rely on nginx access logs parsed by a simple Python script that sends an alert if 500 errors exceed 5 in any 5-minute window. We also run mysqladmin processlist manually during incidents, and we keep a terminal open with tail -f /var/log/nginx/error.log when deploying updates.
The failures we see most often, in order:
-
502 Bad Gateway from PHP-FPM exhaustion. The fix is rarely more workers — it is usually a slow query blocking workers. When 12 workers are all waiting on MySQL, nginx returns 502 regardless of the max_children value. We fix this by identifying the blocking query in the process list, terminating it, and fixing the underlying index or query structure.
-
504 Gateway Timeout during backups. We run
mysqldumpwith--single-transaction --quickto avoid table locks. Before this change, a weekly backup locked the guides table for 20 seconds, which caused nginx timeouts. The single-transaction flag keeps the dump consistent without holding locks for the full duration. -
Disk full from uncompressed logs. Nginx access logs grow quickly on SEO-driven sites because search crawlers generate many requests. We rotate with
logrotatedaily and keep only 7 days of uncompressed logs, then compress to.gzfor 30 days. This is not exciting, but running out of disk on a 20 GB server takes a site offline faster than any code bug. -
SSL certificate expiry. We use
certbotwith a weekly cron job that attempts renewal and logs the result. It fails silently sometimes, so we check the log manually. After one near-expiry incident on hereinuganda.com, we added an alert based onopenssl s_clientchecking the certificate days remaining.
Monitoring numbers we track
We measure three numbers for every production site:
- Average response time: 380 ms for hereinuganda.com, 45 ms for sweetcakesuganda.com (static files).
- Concurrent users handled before slowdown: 25 for the LEMP site, 80+ for the static site (limited by nginx, not PHP or MySQL).
- Weekly downtime incidents: we target zero. In the last 6 months, we had two brief outages — one from a PHP-FPM restart during an update (12 seconds), one from a database backup without
--single-transaction(20 seconds). Both were preventable.
We also track the ratio of cached to uncached requests. For hereinuganda.com, approximately 65 percent of page requests are served from nginx fastcgi cache. That means only 35 percent hit PHP-FPM, which reduces server load and extends the life of our hardware.
What we would change if we rebuilt today
If we started fresh, we would separate the static and dynamic sites onto different servers. The mixed-load problem — one site needing nothing from PHP-FPM and the other needing it intensely — creates unnecessary complexity. A 2 GB server running only the travel platform would be simpler to monitor, easier to scale, and less likely to fail from resource contention.
We would also add a basic health-check endpoint (/healthz) that returns 200 only if PHP-FPM, MySQL, and nginx are all responsive. Our current monitoring relies on page-level error rates, which means we detect failures through user impact. A health check would give us faster alerts.
For anyone running a similar LEMP stack — especially for content sites like ours — we recommend starting with the slow query log enabled, setting fastcgi_cache_use_stale, and defining pm.max_requests defensively. These three settings cost nothing to implement and prevent the majority of failures we have seen in production.
Our experience with sweetcakesuganda.com and hereinuganda.com confirms that production reliability is not about complex tooling. It is about watching the logs, setting defensive timeouts and caches, and fixing the slow queries before they become downtime. You can see more of our work on the projects page and reach out through contact if you are managing a similar site and want to compare notes.