Translating an Apache Config to Nginx
Introduction¶
Apache and Nginx solve the same problem with different models. Apache merges
configuration from the main file, vhosts, <Directory> blocks, and .htaccess
files discovered per request. Nginx evaluates one config tree loaded at startup,
matching server then location blocks. Once you internalise that, most of a
migration is mechanical.
The model difference¶
| Apache | Nginx |
|---|---|
.htaccess (per-directory, runtime) |
No equivalent — all config is central, loaded at start |
<Directory> / <Files> / <Location> |
location blocks (URI-based only) |
Modules do everything (mod_rewrite, mod_proxy) |
Core + a smaller module set, compiled in |
AllowOverride, Options |
n/a |
Reloads pick up .htaccess instantly |
nginx -s reload for any change |
Losing .htaccess is the big one: if you host untrusted user sites that rely on
their own .htaccess, Nginx is a poor fit. For a site you control, moving those
rules into the server config is a one-time job and faster at runtime.
Virtual hosts¶
<VirtualHost *:80>
ServerName example.com
ServerAlias www.example.com
DocumentRoot /var/www/example
ErrorLog /var/log/apache2/example_error.log
CustomLog /var/log/apache2/example_access.log combined
</VirtualHost>
server {
listen 80;
server_name example.com www.example.com;
root /var/www/example;
index index.html index.php;
access_log /var/log/nginx/example_access.log;
error_log /var/log/nginx/example_error.log;
}
DirectoryIndex, Options, directory listing¶
DirectoryIndex index.php index.html
Options -Indexes +FollowSymLinks
index index.php index.html;
autoindex off; # -Indexes; "on" enables listings
# FollowSymLinks is Nginx's default; disable with disable_symlinks on;
Access control¶
# Apache 2.4
<Directory /var/www/example/private>
Require ip 10.0.0.0/8
Require all denied
</Directory>
location /private/ {
allow 10.0.0.0/8;
deny all;
}
Require all granted → nothing needed (open by default). Require valid-user →
auth_basic (see HTTP Basic Auth with htpasswd).
Redirects and rewrites¶
# Simple redirect
Redirect 301 /old /new
# mod_rewrite: force HTTPS
RewriteEngine On
RewriteCond %{HTTPS} off
RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L]
# Front controller (WordPress / Laravel)
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^ index.php [L]
# Simple redirect
location = /old { return 301 /new; }
# Force HTTPS — a whole server block, not a rewrite
server {
listen 80;
server_name example.com www.example.com;
return 301 https://$host$request_uri;
}
# Front controller
location / {
try_files $uri $uri/ /index.php?$query_string;
}
try_files replaces the !-f/!-d dance and is faster. Reach for rewrite
only when you genuinely need regex capture; most RewriteRules become a
location + try_files or a return.
PHP¶
# mod_php or SetHandler to php-fpm
<FilesMatch \.php$>
SetHandler "proxy:unix:/run/php/php8.2-fpm.sock|fcgi://localhost"
</FilesMatch>
location ~ \.php$ {
include fastcgi_params;
fastcgi_pass unix:/run/php/php8.2-fpm.sock;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
try_files $uri =404; # don't pass non-existent .php to FPM
}
Reverse proxy (mod_proxy)¶
ProxyPreserveHost On
ProxyPass /api/ http://127.0.0.1:3000/
ProxyPassReverse /api/ http://127.0.0.1:3000/
RequestHeader set X-Forwarded-Proto "https"
location /api/ {
proxy_pass http://127.0.0.1:3000/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
See An Nginx reverse proxy with TLS for the full production block (timeouts, buffering, websockets).
Headers and expires¶
Header always set X-Frame-Options "SAMEORIGIN"
<FilesMatch "\.(jpg|css|js)$">
Header set Cache-Control "max-age=31536000, immutable"
</FilesMatch>
add_header X-Frame-Options "SAMEORIGIN" always;
location ~* \.(jpg|jpeg|png|gif|css|js|woff2)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
Gotcha: add_header in Nginx does not inherit into a nested location if
that inner block has its own add_header. Apache's Header merges more
forgivingly. Repeat headers in each block, or use a shared include snippet.
SSL/TLS¶
SSLEngine on
SSLCertificateFile /etc/letsencrypt/live/example.com/fullchain.pem
SSLCertificateKeyFile /etc/letsencrypt/live/example.com/privkey.pem
SSLProtocol all -SSLv3 -TLSv1 -TLSv1.1
listen 443 ssl;
http2 on;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
Verification and troubleshooting¶
nginx -t # syntax + can-it-load check (do this before every reload)
nginx -T | less # dump the FULL merged config
curl -sI https://example.com # headers, status, redirects
- 404 for everything after migration —
rootis wrong or is inside alocationwhere it doesn't belong.rootshould be atserverlevel;nginx -Tshows the effective value. .htaccessrules silently ignored — expected; Nginx never reads them. Grep the old tree:find /var/www -name .htaccessand port each.- Redirect loop after "force HTTPS" — TLS terminates at a load balancer and
Nginx sees plain HTTP. Test
$http_x_forwarded_protoinstead of$scheme. - PHP downloads instead of executing — the
location ~ \.php$block is missing, or a broadlocation /withtry_files ... =404matched first. Regex locations beat prefix locations, so ordering usually isn't the issue — a missingfastcgi_passis. - Security headers missing on some pages — an inner
locationhas its ownadd_headerand dropped the inherited ones. Consolidate into aninclude. Options +Indexessites now 403 — setautoindex onin that location if the listing is intentional.
Related tools and reading¶
- On-site: HTTP Header Analyzer, Security Headers Checker, Redirect Checker.
- Related posts: How Nginx location block matching works, An Nginx reverse proxy with TLS.
Stuck on something this site can't fix?Reach out to Prabath directly on LinkedIn.More in Web & Mail Servers
Running BIND 9 as an Authoritative Server
BIND as an authoritative server: zone files, secured AXFR with TSIG, SOA serial rules, and DNSSEC inline-signing. Here's a working master/slave setup.
October 24, 2026Varnish Cache in Front of a Site
Varnish is a caching HTTP accelerator configured in VCL. Here's the request lifecycle, why you strip cookies, how bans work, and grace mode for serving stale during backend trouble.
October 24, 2026Let's Encrypt with Certbot, Including Renewal That Doesn't Break
Getting the first cert is easy; keeping renewal working for years is the part people get wrong. Webroot vs DNS-01, deploy hooks, and the failure modes.
October 24, 2026