Migrating from Apache and .htaccess to Nginx
With Apache, a site owner can set up redirects, password protection or cache settings by placing a .htaccess file in a folder; the server reads that file on every request. Nginx has no such file: all rules live in the server's own configuration files and are read once, when the server is reloaded.
Moving from Apache to Nginx is therefore not a matter of copying files but of translation: for every rule in .htaccess, its Nginx equivalent is written. This guide gives the most common rules as side-by-side examples and explains the usual pitfalls and what to test after the move. If you are new to Nginx, have a look at the what is Nginx guide first.
In brief
- Nginx does not read .htaccess files; the rules move into the server configuration.
- Most RewriteRule lines need no rewrite at all: try_files and return are enough.
- PHP runs through PHP-FPM instead of mod_php; php_value lines move elsewhere.
- Every change is tested with nginx -t and then reloaded.
Tip
Do the migration in a test environment or on a separate subdomain first, not on the live site; keep the old Apache configuration and the .htaccess files so that you can roll back.
On this page
The logic and order of a migration
-
Understand the basic difference
- Where the rules live: With Apache, rules may be scattered across
.htaccessfiles in various folders. With Nginx they are all in the server configuration: typically one file per site containing aserver { … }block. The location of the files varies by distribution (/etc/nginx/conf.dis common;/etc/nginx/sites-availableis the Debian/Ubuntu layout). - Taking effect: A
.htaccessfile applies the moment it is saved. In Nginx a change is tested withnginx -tand takes effect on reload. A faulty configuration is caught by the test; the running site is not affected. - Permissions: Changing the configuration requires server administrator rights. On shared hosting this access is usually not available; there, the hosting provider or the control panel applies the rules.
- Approach: In Apache everything is solved with
RewriteRule. In Nginx the request is first matched to alocation, and then the directives of that location are applied.
: Enlarge - Where the rules live: With Apache, rules may be scattered across
-
Plan the migration
- Take an inventory: Find every
.htaccessfile on the site; those in subfolders (admin panel, upload folder) are easily forgotten. Also look at the rules in the Apache virtual host (VirtualHost) file. - Classify the rules: redirects, URL rewriting, access restrictions, headers, PHP settings. Do not carry over the ones that are no longer used.
- Write the Nginx equivalents (the sections below).
- Test:
nginx -t, then the test list in the test environment. - Switch over and monitor: watch the error log closely during the first few days.
Bash# List every .htaccess file on the site (replace the path with your own site directory) find /var/www/example.com -name ".htaccess" -type f # Test the Nginx configuration; reload if there are no problems sudo nginx -t && sudo systemctl reload nginx
: Enlarge - Take an inventory: Find every
-
Learn how location matching is prioritised
.htaccessrules run in order from top to bottom. In Nginx, by contrast, a request is handled by a singlelocation, and which one is chosen depends not on the order in which they are written but on this priority:location = /address: if there is an exact match, it is chosen immediately.- Among the prefix locations, the longest match is found. If that location is written with
^~, it is chosen and regular expressions are not checked. - Regular-expression locations (
~case-sensitive,~*case-insensitive) are tried in the order they appear in the file; the first match is chosen. - If no regular expression matches, the longest prefix location found in step 2 is used.
The consequence: password protection written inside
location /admin/is not applied to a request for/admin/index.php, because that request is handled bylocation ~ \.php$. The solution is to write the location with^~and nest the PHP location inside it (see the example in the access restriction section).
: Enlarge -
Connect PHP to PHP-FPM
In most installations Apache runs PHP inside itself (mod_php). Nginx has no PHP built in; it passes
.phprequests to the separately running PHP-FPM service withfastcgi_pass.Nginxlocation ~ \.php$ { # Do not send the request to PHP-FPM if the file does not exist try_files $uri =404; include fastcgi_params; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; # The socket path varies by distribution and PHP version fastcgi_pass unix:/run/php/php-fpm.sock; }The line
try_files $uri =404;prevents a non-existent.phpaddress from being sent to PHP-FPM. For the socket path and pool settings, see the guide on Nginx and PHP-FPM.
: Enlarge
Table of equivalents
| Task | Apache / .htaccess | Nginx |
|---|---|---|
| Front controller | RewriteCond !-f, !-d + RewriteRule | try_files $uri $uri/ /index.php?$query_string; |
| HTTPS and www | RewriteCond %{HTTPS} + RewriteRule [R=301] | separate server block + return 301 |
| Single-address redirect | Redirect 301 | location = … { return 301 …; } |
| Clean URL | RewriteRule … [L] | rewrite … last; or a location |
| Password protection | AuthType Basic, AuthUserFile | auth_basic, auth_basic_user_file |
| IP restriction | Require ip | allow / deny |
| Directory listing | Options -Indexes | autoindex off; (already off by default) |
| Error page | ErrorDocument | error_page |
| Cache headers | mod_expires (ExpiresByType) | expires |
| Response header | Header set | add_header |
| PHP | mod_php | PHP-FPM + fastcgi_pass |
| PHP setting | php_value, php_flag | PHP-FPM pool setting or .user.ini |
Front controller
Most PHP frameworks and content management systems send every address that does not exist to the index.php file. In Apache this is done with a RewriteRule carrying the conditions "not a file, not a folder":
RewriteEngine On
# If the request is not for a real file or folder, send it to index.php
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^ index.php [L]In Nginx the same job takes one line. try_files tries the file, then the folder, in turn; if neither exists, it passes the request to the last value:
root /var/www/example.com/public;
index index.php index.html;
# Form 1: the application reads the path from REQUEST_URI (most frameworks)
location / {
try_files $uri $uri/ /index.php?$query_string;
}
# Form 2: the application reads the path from a parameter (index.php?url=...)
# Use these two instead of the "location /" above:
#
# location / {
# try_files $uri $uri/ @app;
# }
# location @app {
# rewrite ^/(.*)$ /index.php?url=$1 last;
# }If your application reads the path from a query parameter (e.g. index.php?url=…), use the second form in the example. rewrite appends the original query string to the new address automatically (the counterpart of QSA in Apache).
HTTPS and www redirects
RewriteEngine On
RewriteCond %{HTTPS} off [OR]
RewriteCond %{HTTP_HOST} !^www\. [NC]
RewriteRule ^ https://www.example.com%{REQUEST_URI} [L,R=301]In Nginx no condition is written for this. A separate server block is opened for each name to be redirected, containing nothing but return 301; the actual site stays in its own block:
# 1) Everything arriving over HTTP -> HTTPS + www
server {
listen 80;
server_name example.com www.example.com;
return 301 https://www.example.com$request_uri;
}
# 2) HTTPS but without www -> www
server {
listen 443 ssl;
server_name example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
return 301 https://www.example.com$request_uri;
}
# 3) The actual site
server {
listen 443 ssl;
server_name www.example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
root /var/www/example.com/public;
index index.php index.html;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
}To be able to redirect a request for example.com that arrives over HTTPS, that block needs a valid certificate as well. For certificate installation, see the guide on HTTPS in Nginx.
Single-address redirects and clean URLs
# Single-address redirect
Redirect 301 /old-page.html https://www.example.com/new-page
# Moving a whole section
RedirectMatch 301 ^/blog/(.*)$ https://www.example.com/articles/$1
# Clean URL: /product/42 -> product.php?id=42
RewriteEngine On
RewriteRule ^product/([0-9]+)/?$ product.php?id=$1 [L,QSA]# Single-address redirect
location = /old-page.html {
return 301 https://www.example.com/new-page;
}
# Moving a whole section (query parameters are preserved)
location ~ ^/blog/(.*)$ {
return 301 https://www.example.com/articles/$1$is_args$args;
}
# Clean URL: /product/42 -> /product.php?id=42
# The pattern starts with /; the original query string is appended automatically
rewrite ^/product/([0-9]+)/?$ /product.php?id=$1 last;- Patterns in
.htaccesshave no leading slash (^product/…); in Nginx the address always starts with/(^/product/…). This is the detail most often forgotten during translation. - If all you need is a redirect, use
return 301rather thanrewrite … permanent; it is simpler and easier to read. lastrestarts thelocationsearch with the new address, so that/product.phpreaches the PHP location.- If you have dozens of old addresses to redirect, building a lookup table with the
mapdirective is tidier than writing a separatelocationfor each one.
Directory protection, IP restriction and hidden files
# /admin/.htaccess : password protection
AuthType Basic
AuthName "Panel"
AuthUserFile /home/example/.htpasswd
Require valid-user
# /reports/.htaccess : specific IP addresses only (Apache 2.4)
Require ip 203.0.113.10
Require ip 10.0.0.0/24
# Root .htaccess : switch off directory listing, block addresses starting with a dot
Options -Indexes
RewriteEngine On
RewriteRule (^|/)\.(?!well-known) - [F]# Files and folders starting with a dot (.htaccess, .env, .git ...) are blocked;
# .well-known stays open for certificate validation. Write this before the other regular-expression locations.
location ~ /\.(?!well-known) {
deny all;
}
# Password protection. ^~ : regular-expression locations (e.g. \.php$) are not checked,
# so the PHP location is defined again inside and the protection covers .php files too.
location ^~ /admin/ {
auth_basic "Panel";
auth_basic_user_file /etc/nginx/.htpasswd;
location ~ \.php$ {
try_files $uri =404;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_pass unix:/run/php/php-fpm.sock;
}
}
# Specific IP addresses only
location ^~ /reports/ {
allow 203.0.113.10;
allow 10.0.0.0/24;
deny all;
location ~ \.php$ {
try_files $uri =404;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_pass unix:/run/php/php-fpm.sock;
}
}
# Directory listing: already off by default; writing it explicitly is optional
autoindex off;- Password file: A file created with
htpasswdcan in most cases be used in Nginx as well. Confirm that the password hash type is supported by actually logging in; keep the file outside the web root. - IP restriction:
allowanddenyare evaluated in the order written; the first matching rule applies. If a CDN or another proxy sits in front of the site, Nginx sees the proxy's IP address rather than the visitor's; unless the real address is configured separately, the IP restriction will not work as you expect. - Directory listing: In Nginx
autoindexis off by default; writing an equivalent for theOptions -Indexesline is not required. - Hidden files: Apache's default configuration hides files starting with
.htby itself; Nginx has no such default. After the migration, the contents of any leftover.htaccess,.envor.gitcan be downloaded as plain text. Be sure to add the location that blocks addresses starting with a dot, and write it before the other regular-expression locations. - "Deny from all" in subfolders: The
.htaccessprotections in upload or backup folders no longer work either; write alocationfor each of them. See Nginx security settings.
Error pages and cache headers
# Custom error pages
ErrorDocument 404 /404.html
ErrorDocument 500 /500.html
# Cache headers (mod_expires)
<IfModule mod_expires.c>
ExpiresActive On
ExpiresByType text/css "access plus 30 days"
ExpiresByType application/javascript "access plus 30 days"
ExpiresByType image/webp "access plus 90 days"
ExpiresByType image/jpeg "access plus 90 days"
</IfModule># Custom error pages
error_page 404 /404.html;
error_page 500 502 503 504 /50x.html;
# Cache headers
location ~* \.(?:css|js)$ {
expires 30d;
}
location ~* \.(?:webp|jpg|jpeg|png|gif|svg|ico)$ {
expires 90d;
}For error responses generated by PHP (a 404 returned by the application, for example), error_page does not step in by itself; that requires fastcgi_intercept_errors on; in the relevant location. For cache lifetimes and compression, see the guide on caching and compression.
php_value and php_flag lines
php_value and php_flag lines in .htaccess only work with mod_php. Nginx does not read them, and neither does PHP-FPM. There are two equivalents:
- PHP-FPM pool setting:
php_value[…]/php_flag[…], orphp_admin_value[…]/php_admin_flag[…], which the application cannot override. PHP-FPM is reloaded after the change. .user.ini: Placed in the site folder and needing no reload; however, it only applies to settings that can be changed per directory, and because PHP re-reads the file at intervals (300 seconds by default) a change may not show up straight away.
# Works with mod_php only; Nginx + PHP-FPM does not read these lines
php_value upload_max_filesize 32M
php_value post_max_size 32M
php_value memory_limit 256M
php_flag display_errors off; Option 1: a .user.ini file in the site's root directory
upload_max_filesize = 32M
post_max_size = 32M
memory_limit = 256M
display_errors = Off
; Option 2: the PHP-FPM pool file (e.g. www.conf; its location varies by distribution)
; A value given with php_admin_* cannot be changed by the application with ini_set()
php_admin_value[upload_max_filesize] = 32M
php_admin_value[post_max_size] = 32M
php_admin_value[memory_limit] = 256M
php_admin_flag[display_errors] = offIf you are carrying over an upload limit, do not forget the Nginx side: client_max_body_size defaults to 1m; unless it is raised, large uploads are rejected with a 413 error before they ever reach PHP.
The "if" directive and other pitfalls
ifis not a programming-language condition. Inside alocation, the directives that can safely be used withifarereturnandrewrite; combined with other directives it can produce unexpected results. Rather than translatingRewriteCondlines one-to-one intoif, first ask: can this be done withtry_files,return, a separateserverorlocationblock, ormap? Most of the time it can.- The difference in order:
.htaccessrules are processed in sequence and affect one another; in Nginx a single location is selected. Writing the rules one below the other in the same order does not give the same result. - Inheritance: Directives such as
add_headerare not inherited from the level above once the same directive is written on the lower level. - Ready-made converters: Online converters can provide a good first draft, but their output often contains unnecessary
ifblocks. Review every line and make sure you understand it. - The application's own
.htaccess: Off-the-shelf software (content management systems, frameworks) generates a.htaccesson installation and may change it during updates. Take the Nginx equivalent of those rules from the software's documentation and check it again after updates.
Test list after the migration
# Redirects: status code and Location header
curl -sI http://example.com/ | grep -i -E "^HTTP|^location"
curl -sI https://example.com/ | grep -i -E "^HTTP|^location"
curl -sI https://www.example.com/old-page.html | grep -i -E "^HTTP|^location"
# Hidden files: should return 403 or 404, with no content
curl -s -o /dev/null -w "%{http_code}\n" https://www.example.com/.htaccess
curl -s -o /dev/null -w "%{http_code}\n" https://www.example.com/.env
curl -s -o /dev/null -w "%{http_code}\n" https://www.example.com/.git/config
# Password-protected area: should return 401 (including .php)
curl -s -o /dev/null -w "%{http_code}\n" https://www.example.com/admin/index.php
# Non-existent address: 404
curl -s -o /dev/null -w "%{http_code}\n" https://www.example.com/no-such-page- The home page, an inner page and a page with a clean URL (a product or an article, for example) open.
http://and the address without www go to the correct HTTPS address with a 301 in a single step.- Redirects from old addresses work; query parameters are preserved.
- A non-existent address returns 404 and the custom error page; so does a non-existent
.phpaddress. - The admin panel asks for a password, including for
.phpfiles. - Addresses such as
/.htaccess,/.envand/.git/configare not accessible. - PHP does not run in the upload folder; no directory listing appears.
- Form submission, login and file upload (including a large file) work.
- Static files arrive with the correct
Cache-ControlandContent-Type. - The security headers are in place (see security headers).
- There are no unexpected entries in the Nginx and PHP-FPM error logs.
Frequently asked questions
Can I copy my .htaccess file to Nginx as it is?
No. Nginx does not read .htaccess files, and the syntax is different. The equivalent of each rule is written into the server configuration; as far as Nginx is concerned, the file itself is an ordinary text file and access to it must be blocked.
Can I write Nginx rules myself on shared hosting?
Usually not; the server configuration requires administrator rights. Some control panels offer a limited area for this or run Nginx in front of Apache, in which case .htaccess keeps working. Ask your hosting provider.
Does .htaccess work if I use Nginx in front of Apache?
Yes. If Nginx is placed in front as a reverse proxy and passes requests on to Apache, Apache still applies the .htaccess rules. Only static files that Nginx serves directly do not pass through those rules.
Do I have to write an "if" for every RewriteCond?
No; most of the time it is not needed. The file-exists check is covered by try_files, domain and HTTPS conditions by separate server blocks, and address-based conditions by location. "if" should only be used together with return or rewrite, and only when there is no other way.
After the migration the site is slow or returns 502. Where should I look?
Start with the Nginx error log and the PHP-FPM log. A 502 usually means that PHP-FPM is not running or that the socket path is wrong.
BYK Yazılım Support Team
This guide is written and regularly reviewed by the BYK Yazılım support team. Last updated: 4 October 2026.
Related guides
- Nginx and PHP-FPM: how PHP sites run on Nginxfastcgi_pass, SCRIPT_FILENAME, try_files, the pool concept, socket permissions and the 502 error.
- Nginx security settings: rate limiting, headers, access restrictionsRate limiting, security headers, hidden-file and admin-path restrictions, and request limits in Nginx.
- HTTPS in Nginx: setting up an SSL certificateGetting a certificate, redirecting to HTTPS, HSTS, SSL termination and automatic renewal in Nginx.
- 502, 504 and other Nginx errors: causes and fixesWhat 502, 504, 413, 499, 403 and 404 mean, their likely causes, diagnosis from the logs and the fix.
Let us talk about your website infrastructure
BYK Yazılım builds corporate websites. Write to us with any questions about your site.
Contact us Our corporate website service