Server

Translating an Apache Config to Nginx

By Prabath Thalangama· October 24, 2026· 4 min read
#nginx#apache#migration

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 — root is wrong or is inside a location where it doesn't belong. root should be at server level; nginx -T shows the effective value.
  • .htaccess rules silently ignored — expected; Nginx never reads them. Grep the old tree: find /var/www -name .htaccess and port each.
  • Redirect loop after "force HTTPS" — TLS terminates at a load balancer and Nginx sees plain HTTP. Test $http_x_forwarded_proto instead of $scheme.
  • PHP downloads instead of executing — the location ~ \.php$ block is missing, or a broad location / with try_files ... =404 matched first. Regex locations beat prefix locations, so ordering usually isn't the issue — a missing fastcgi_pass is.
  • Security headers missing on some pages — an inner location has its own add_header and dropped the inherited ones. Consolidate into an include.
  • Options +Indexes sites now 403 — set autoindex on in that location if the listing is intentional.
PrabathStuck on something this site can't fix?Reach out to Prabath directly on LinkedIn.