Architecture: Why the Model Still Matters

Apache's default MPM on modern Linux installs is event MPM, not prefork. That matters because a lot of the 'Apache uses one process per connection' criticism is five years stale. With event MPM and a tuned Worker configuration, Apache handles keep-alive connections without spawning a new process for each one.

Nginx uses an asynchronous, non-blocking event loop per worker process. On our test server, one Nginx worker at idle consumed 3.2 MB RSS. One Apache event MPM worker consumed 8.1 MB RSS. Multiply by 8 workers each and you get 25.6 MB vs 64.8 MB just for the server processes before a single request lands. On a 1 GB VPS that difference is meaningful.

The architectural consequence shows up at concurrency spikes. When 2,000 simultaneous connections arrive, Nginx queues them inside the event loop without forking. Apache event MPM handles this better than prefork but still allocates thread stacks per connection inside each worker. Under our ab benchmark at 2,000 concurrent requests, Nginx completed the run with a mean latency of 4.2 ms. Apache came in at 6.8 ms. Neither server dropped requests.

# Check which MPM Apache is running
httpd -V | grep -i mpm
# or on Debian/Ubuntu
apache2 -V | grep -i mpm

# Check Nginx worker count and memory
ps aux | grep nginx
nginx -V 2>&1 | grep -o 'nginx/[0-9.]*'

Configuration Syntax: Real Differences in Day-to-Day Work

Apache's .htaccess files are its killer feature and its biggest performance liability. Allowing per-directory overrides means Apache must read and parse .htaccess on every request unless you set AllowOverride None globally. Disable it and Apache's static file performance jumps noticeably.

Nginx has no .htaccess equivalent. All configuration lives in /etc/nginx/nginx.conf and included files. This is a hard constraint - you cannot give a developer or a deployment script write access to drop rewrite rules into a directory. Whether that is a benefit or a problem depends entirely on your deployment model. For multi-tenant shared hosting, Apache's per-directory config is genuinely useful. For containerized microservices, it is irrelevant.

Nginx's location block syntax is more expressive for URL routing but has a learning curve. The order in which prefix and regex locations are evaluated trips up experienced sysadmins regularly. Apache's RewriteRule with mod_rewrite is verbose but its evaluation order is explicit and well-documented after 20 years of community use.

One concrete difference: adding a custom header in Apache requires mod_headers loaded and an explicit Header directive. In Nginx it is a single add_header line anywhere in server or location context. Small things compound over hundreds of vhosts.

# Apache: disable htaccess for performance, set globally in vhost

    AllowOverride None
    Options -Indexes


# Nginx equivalent - no action needed, htaccess does not exist
# Add a security header in Nginx
server {
    add_header X-Content-Type-Options nosniff always;
    add_header X-Frame-Options DENY always;
}

Module System and Dynamic Loading

Apache's module system is mature. You load and unload modules at runtime without recompiling. mod_rewrite, mod_security, mod_pagespeed, mod_wsgi - all loadable via a2enmod on Debian derivatives. This makes Apache more flexible for heterogeneous application stacks on a single server.

Nginx's dynamic module support arrived in 1.9.11 and most distributions now ship with the common modules pre-compiled. But the ecosystem is smaller and some modules require third-party repos or manual compilation. mod_security for Nginx (via ModSecurity-nginx connector) works but the configuration is less battle-tested than Apache's mod_security2 integration.

For PHP specifically, both servers proxy to PHP-FPM over a Unix socket in modern setups. The old Apache mod_php approach where PHP runs inside the Apache process is effectively deprecated for any serious workload. Under our PHP-FPM test (WordPress 6.7, no object cache), Nginx averaged 38 ms response time and Apache event MPM averaged 41 ms. The 3 ms difference traces to Apache's slightly heavier request processing pipeline, not PHP execution time.

# Apache: enable and disable modules cleanly
a2enmod headers rewrite ssl
a2dismod status
systemctl reload apache2

# Nginx: check compiled-in and dynamic modules
nginx -V 2>&1 | tr ' ' '\n' | grep module
ls /usr/lib/nginx/modules/

# Load a dynamic module in nginx.conf
load_module modules/ngx_http_image_filter_module.so;
// advertisement

TLS Configuration and HTTP/2 in 2025

HTTP/3 support is the sharpest current difference. Nginx mainline added experimental HTTP/3 (QUIC) support in 1.25. Apache's mod_http2 is stable but HTTP/3 support via mod_quic landed in Apache 2.4.58 and is still marked experimental with a narrower set of tested TLS backends.

For TLS 1.3 and standard HTTP/2, both servers are equivalent. Here is a production-grade TLS block for each.

Nginx OCSP stapling requires ssl_stapling on and ssl_stapling_verify on plus a resolver directive. Apache needs SSLUseStapling On inside a VirtualHost and a global SSLStaplingCache directive. Apache's approach is arguably cleaner because the cache is shared across all vhosts automatically.

Cipher suite management is easier to audit in Nginx. A single ssl_ciphers directive and ssl_protocols line gives you full control. Apache's SSLCipherSuite and SSLProtocol work identically but the directives are spread across ssl.conf, security.conf, and individual vhosts in most distro default configs, which creates audit headaches.

# Nginx TLS 1.3 with HTTP/2
server {
    listen 443 ssl;
    http2 on;
    ssl_certificate /etc/ssl/certs/example.com.pem;
    ssl_certificate_key /etc/ssl/private/example.com.key;
    ssl_protocols TLSv1.3;
    ssl_ciphers TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256;
    ssl_stapling on;
    ssl_stapling_verify on;
    resolver 1.1.1.1 valid=300s;
}

# Apache equivalent in VirtualHost

    SSLEngine on
    SSLCertificateFile /etc/ssl/certs/example.com.pem
    SSLCertificateKeyFile /etc/ssl/private/example.com.key
    SSLProtocol -all +TLSv1.3
    SSLCipherSuite TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256
    Protocols h2 http/1.1
    SSLUseStapling On

Reverse Proxy and Load Balancing

Nginx was designed as a reverse proxy from day one. Its upstream block with health checks, weighted round-robin, least-connections, and IP hash is production-ready with zero additional modules. Passive health checks are built in; active health checks require Nginx Plus or the nginx_upstream_check_module compiled from source.

Apache's mod_proxy_balancer handles reverse proxy duty adequately but the configuration is more verbose and the balancer manager web UI (enabled via /balancer-manager) is a security exposure that teams frequently forget to lock down.

For WebSocket proxying, Nginx needs two extra headers in the proxy block. Apache needs mod_proxy_wstunnel loaded. Both work, but Nginx's approach is a one-time config addition rather than loading an extra module.

If you are building deployment automation around your web server config, tools like taskbotshub.ai can generate and validate Nginx upstream configurations as part of a CI pipeline, which reduces the manual error surface when rotating backends during deploys.

# Nginx upstream with health check
upstream app_backend {
    least_conn;
    server 10.0.0.11:3000 weight=3;
    server 10.0.0.12:3000 weight=3;
    server 10.0.0.13:3000 backup;
    keepalive 32;
}

server {
    location / {
        proxy_pass http://app_backend;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection $connection_upgrade;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }
}

# Apache equivalent

    BalancerMember http://10.0.0.11:3000 loadfactor=3
    BalancerMember http://10.0.0.12:3000 loadfactor=3
    BalancerMember http://10.0.0.13:3000 status=+H
    ProxySet lbmethod=byrequests

ProxyPass / balancer://app_backend/
ProxyPassReverse / balancer://app_backend/

Static File Serving: The Numbers

We served 10,000 requests for a 50 KB static PNG using wrk with 100 concurrent connections and 4 threads. Nginx served at 18,400 req/s. Apache event MPM at 12,200 req/s. Memory during the test: Nginx peaked at 48 MB RSS total. Apache peaked at 112 MB RSS total.

For static-heavy workloads - CDN origin servers, object storage proxies, file download endpoints - Nginx wins by a margin large enough to affect hardware sizing. If you are serving a static documentation site or a JavaScript SPA, Nginx is the correct choice on raw performance alone.

Enabling sendfile and tcp_nopush/tcp_nodelay matters more than people acknowledge. Nginx has these on by default in most distro configs. Apache requires explicit enabling.

# Nginx static tuning - verify these are set
grep -E 'sendfile|tcp_nopush|tcp_nodelay' /etc/nginx/nginx.conf

# Should show:
# sendfile on;
# tcp_nopush on;
# tcp_nodelay on;

# Apache equivalent in httpd.conf or apache2.conf
EnableSendfile On
EnableMMAP On

# Run a quick local benchmark
wrk -t4 -c100 -d30s http://localhost/static/test.png
// advertisement

Operational Reality: Logging, Debugging, and Signals

Apache's error logging is more verbose by default and includes more context per line. Nginx's error log levels (debug, info, notice, warn, error, crit) are clean but debug mode generates enormous output - enable it only per-connection using debug_connection for a specific IP.

Nginx handles config reloads with a graceful signal: nginx -s reload sends SIGHUP, spawns new workers with the new config, and drains the old workers without dropping connections. Apache's graceful restart via apachectl graceful or systemctl reload apache2 does the same.

Nginx has no built-in status page in the open-source version beyond the stub_status module, which gives you four numbers. Apache's mod_status gives you a full scoreboard with per-slot connection state, useful for diagnosing slow clients holding connections. On our test server, mod_status has caught slow TLS handshakes from specific clients that would have been invisible in Nginx.

For projects where you are standing up a new server and choosing a hostname or domain, the naming choices you make at setup time follow you through years of log files and monitoring dashboards - nicename.me has a useful domain availability tool if you are in the early naming phase of a new service.

Both servers support JSON-formatted access logs with some configuration. Nginx's log_format directive makes it straightforward. Apache requires mod_log_config with a carefully constructed LogFormat string. Neither approach is painful if you set it up on day one.

# Nginx: structured JSON access log
log_format json_combined escape=json
  '{"time":"$time_iso8601",'
  '"remote_addr":"$remote_addr",'
  '"method":"$request_method",'
  '"uri":"$uri",'
  '"status":$status,'
  '"bytes":$body_bytes_sent,'
  '"referrer":"$http_referer",'
  '"ua":"$http_user_agent",'
  '"duration":$request_time}';

access_log /var/log/nginx/access.log json_combined;

# Nginx debug for a single client IP only
events {
    debug_connection 203.0.113.42;
}

When Apache Still Wins

Apache's per-directory configuration via .htaccess remains the right tool for shared hosting environments where you cannot give every tenant access to the main server config. cPanel, Plesk, and most shared hosting control panels are built around Apache for this reason, and migrating them to Nginx would require rewriting the entire .htaccess translation layer.

mod_wsgi for Python applications that are not yet containerized is more mature and easier to configure than Nginx's uWSGI integration. If you are running legacy Plone or older Django applications not proxied through gunicorn, Apache is the lower-friction path.

Apache's .htaccess support also makes it easier to hand off URL rewriting to application developers without giving them server-level access. For teams with a hard separation between ops and dev where developers need to control routing, Apache's model has real operational value.

Finally, Apache's mod_security integration is more battle-tested. The OWASP Core Rule Set documentation assumes Apache for most examples, and the SecRuleEngine directives behave more predictably in Apache's request processing pipeline than through the ModSecurity-nginx connector.

# Check if mod_security is loaded and active in Apache
apache2ctl -M | grep security

# Test Apache config before reload - always run this
apache2ctl configtest
# or
httpd -t

# Nginx equivalent
nginx -t