Migrating from Apache .htaccess to Ferron 3 (PHP hosting)
This guide helps you replace an Apache + mod_php/mod_rewrite setup with Ferron 3 in front of PHP-FPM. It maps the .htaccess patterns you know to Ferron 3 directives. It also highlights differences in how the two servers approach configuration.
The examples assume a single WordPress-style site served through PHP-FPM over a Unix socket. The patterns apply equally to Joomla and other PHP applications.
Before you start: how Ferron differs from .htaccess#
Apache evaluates .htaccess per directory, per request, at runtime. Ferron uses a single central configuration file (ferron.conf) and validates it at startup. There is no per-directory .htaccess file. Changes require a server reload.
| Concept | Apache .htaccess | Ferron 3 |
|---|---|---|
| Config location | One .htaccess per directory in the web root | One ferron.conf for the whole server |
| PHP execution | mod_php, mod_proxy_fcgi + SetHandler | fcgi_php (FastCGI to PHP-FPM) |
| URL rewriting | RewriteRule/RewriteCond | rewrite (regex) |
| Path matching | Directory context + RewriteBase | location (prefix) + match (expressions) |
| Access rules | Allow/Deny, Require | allow/block |
| Error pages | ErrorDocument | error_page |
| Headers | Header / RequestHeader | header / request_header |
| Auth | AuthType Basic + htpasswd | basic_auth (hashed passwords) |
Ferron deliberately does not read .htaccess files. After migration, leaving an .htaccess in the document root has no effect. Move every relevant rule into ferron.conf.
PHP-FPM prerequisites#
Install and run PHP-FPM, and point Ferron at its socket. For example, on Debian/Ubuntu:
sudo apt install php8.4-fpm
sudo systemctl enable --now php8.4-fpmMake the socket reachable by the Ferron process:
; /etc/php/8.4/fpm/pool.d/www.conf
listen = /run/php/php8.4-fpm.sock
listen.owner = ferron
listen.group = ferronThen reference it from Ferron with fcgi_php "unix:///run/php/php8.4-fpm.sock".
A complete WordPress example#
Here is a full Ferron 3 configuration that reproduces the typical WordPress .htaccess + Apache vhost setup. It covers PHP-FPM execution, the front-controller rewrite, and HTTPS enforcement. It also covers a www→non-www canonical redirect, custom error pages, IP-based admin protection, and baseline security headers.
example.com {
root /var/www/html
fcgi_php "unix:///run/php/php8.4-fpm.sock"
# Front-controller fallback: route anything that is not a real file or
# directory to index.php (WordPress reads REQUEST_URI to route).
rewrite "^(.*)$" "/index.php" {
file false
directory false
}
# HTTPS enforcement (enabled by default once TLS is configured).
https_redirect
# Custom error pages.
error_page 404 /var/www/html/404.html
error_page 500 502 503 504 /var/www/html/50x.html
# Baseline security headers.
header "X-Content-Type-Options" "nosniff"
header "X-Frame-Options" "DENY"
header "Referrer-Policy" "strict-origin-when-cross-origin"
header "Strict-Transport-Security" "max-age=31536000; includeSubDomains"
match WP_ADMIN {
request.uri ~ r"/wp-login\.php|/wp-admin(?:/|$)"
}
# Protect the admin area by IP.
if WP_ADMIN {
allow "203.0.113.0/24"
block "0.0.0.0/0"
}
}
# Canonical redirect: www -> non-www (handles both HTTP and HTTPS in one 301).
www.example.com {
https_redirect false
status 301 {
location "https://example.com{{request.uri}}"
}
}The sections below explain each pattern in detail.
Front-controller pattern (WordPress, Joomla)#
Apache .htaccess for WordPress typically looks like this:
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteBase /
RewriteRule ^index\.php$ - [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /index.php [L]
</IfModule>The goal: serve real files directly, and send everything else to index.php, which uses the request path to route internally.
In Ferron, fcgi_php already sends .php files to PHP-FPM, and root serves existing static files. The only remaining piece is the fallback for URLs that do not map to real files. Use rewrite with file false and directory false:
example.com {
root /var/www/html
fcgi_php "unix:///run/php/php8.4-fpm.sock"
# Only rewrite when the URL is NOT an existing file or directory.
rewrite "^(.*)$" "/index.php" {
file false
directory false
}
}file false: do not apply this rule when the URL corresponds to an existing file.directory false: do not apply when it corresponds to an existing directory.
This produces the same behavior as the Apache rules. Ferron serves existing files (CSS, images, uploaded .php) as-is, and clean URLs fall through to index.php. WordPress/Joomla read REQUEST_URI (set automatically by Ferron to the original request URI), so you do not need query-string juggling.
If your application expects the original path in PATH_INFO instead of REQUEST_URI, rewrite to the script plus the captured group: rewrite "^(.*)$" "/index.php/$1".
Joomla#
Joomla uses the same front-controller idea with index.php as the entry script, so the rule is identical:
example.com {
root /var/www/joomla
fcgi_php "unix:///run/php/php8.4-fpm.sock"
rewrite "^(.*)$" "/index.php" {
file false
directory false
}
}Serving some paths without PHP#
If a subtree should be static-only (no PHP execution), disable FastCGI with fcgi_php false in a location block. This mirrors the SetHandler none / <Files> exceptions in Apache:
example.com {
root /var/www/html
fcgi_php "unix:///run/php/php8.4-fpm.sock"
location /static {
fcgi_php false
root /var/www/static
}
}www / non-www canonical redirect#
Apache:
RewriteEngine On
RewriteCond %{HTTP_HOST} ^www\.(.*)$ [NC]
RewriteRule ^(.*)$ https://%1/$1 [R=301,L]Ferron has no host-rewriting RewriteRule. Instead, declare a separate host block for the www name that returns a 301 to the canonical host. Use https_redirect false on the www block so a single redirect goes straight to HTTPS on the canonical host:
www.example.com {
https_redirect false
status 301 {
location "https://example.com{{request.uri}}"
}
}
example.com {
root /var/www/html
fcgi_php "unix:///run/php/php8.4-fpm.sock"
# https_redirect is enabled by default because TLS is configured.
}The {{request.uri}} placeholder expands to the original path and query string, so /about?ref=newsletter on www.example.com becomes https://example.com/about?ref=newsletter.
To redirect the other way (non-www → www), swap the host names in the two blocks.
HTTPS redirect#
Apache forces HTTPS with:
RewriteEngine On
RewriteCond %{HTTPS} off
RewriteRule ^(.*)$ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L]In Ferron, HTTPS redirection is automatic once a host name has TLS enabled. When you declare a host by name (for example, example.com) with no explicit port, Ferron starts two listeners. It listens on HTTP (:80) and HTTPS (:443). It issues a 308 Permanent Redirect from HTTP to HTTPS by default. Unless you want to disable it, you do not need to configure anything:
example.com {
https_redirect # default when TLS is present
root /var/www/html
fcgi_php "unix:///run/php/php8.4-fpm.sock"
}Set https_redirect false only if you intentionally serve plain HTTP (for example, behind a TLS-terminating load balancer that already redirects).
The 308 status preserves the request method and body, so POST form submissions redirect correctly. This differs from the common Apache R=301, which can change POST to GET.
IP-based access control (ACLs)#
Apache (legacy Allow/Deny):
Order Deny,Allow
Deny from all
Allow from 203.0.113.0/24Apache (current Require):
Require ip 203.0.113.0/24Ferron uses allow and block. When allow is present, Ferron permits only the listed networks. Everything else gets 403 Forbidden. Add block entries to deny specific addresses even within an allowed range (block always wins over allow):
example.com {
root /var/www/html
fcgi_php "unix:///run/php/php8.4-fpm.sock"
# Allow only the office network, deny one bad host inside it.
allow "203.0.113.0/24"
block "203.0.113.50"
}Place these inside a location or if block to protect a single path (such as /wp-admin or /administrator).
If Ferron sits behind a reverse proxy or load balancer, configure client_ip_from_header with a trusted_proxy list. The IP rules then evaluate the real client IP rather than the address of the proxy.
Deny access to sensitive files#
Apache commonly blocks dotfiles and config files:
<FilesMatch "^\.">
Require all denied
</FilesMatch>In Ferron, use a named matcher with if and a custom 403 status:
match sensitive_path {
request.uri.path ~ "^/(?:\\.|wp-config\\.php|\\.env)"
}
example.com {
root /var/www/html
fcgi_php "unix:///run/php/php8.4-fpm.sock"
if sensitive_path {
status 403 {
body "Access denied"
}
}
}Custom error pages#
Apache:
ErrorDocument 404 /404.html
ErrorDocument 500 /50x.htmlFerron uses error_page, mapping one or more status codes to an absolute file path:
example.com {
root /var/www/html
fcgi_php "unix:///run/php/php8.4-fpm.sock"
error_page 404 /var/www/html/404.html
error_page 500 502 503 504 /var/www/html/50x.html
}When reverse proxying (rather than serving PHP directly), enable intercept_errors on the proxy block. Ferron then replaces upstream 5xx responses with your custom page:
example.com {
location / {
proxy http://127.0.0.1:3000 {
intercept_errors
}
}
error_page 502 /var/www/html/502.html
error_page 503 /var/www/html/503.html
}Ferron skips missing error page files silently and uses its built-in page instead, so always verify the paths exist.
Directory listings#
Apache enables or disables indexes per directory:
Options +Indexes
# or
Options -IndexesFerron disables directory_listing by default. Enable it to mirror +Indexes. Leave it off (the default) to mirror -Indexes:
example.com {
root /var/www/html
directory_listing # mirrors Options +Indexes
# Optionally
#index index.php index.html
}index controls the files that Ferron tries when a client requests a directory. Ferron defaults to index.html index.htm index.xhtml and also includes index.php for PHP entry points when using PHP-FPM.
Security and other response headers#
Apache:
Header always set X-Content-Type-Options "nosniff"
Header always set Content-Security-Policy "default-src 'self'"
Header unset X-Powered-ByThe header directive has three forms: add (+), remove (-), and replace (bare name):
example.com {
root /var/www/html
fcgi_php "unix:///run/php/php8.4-fpm.sock"
header +X-Content-Type-Options "nosniff"
header +Content-Security-Policy "default-src 'self'"
header -X-Powered-By
header Server "Ferron"
}Apply headers inside a location block for per-path policies (for example Cache-Control: no-store on /wp-admin).
Password-protected areas (Basic Auth)#
Apache uses htpasswd files:
AuthType Basic
AuthName "Admin Area"
AuthUserFile /etc/apache2/.htpasswd
Require valid-userThe basic_auth directive does not read htpasswd files. It expects hashed passwords (Argon2id recommended). Generate a hash with the ferron-passwd tool that ships with Ferron, then list the user in the config:
example.com {
root /var/www/html
match WP_ADMIN {
request.uri ~ r"/wp-login\.php|/wp-admin(?:/|$)"
}
if WP_ADMIN {
basic_auth {
realm "Admin Area"
users {
admin "$argon2id$v=19$m=19456,t=2,p=1$..."
}
}
}
}Brute-force protection is on by default. You can tune it:
example.com {
basic_auth {
realm "Admin Area"
users {
admin "$argon2id$v=19$m=19456,t=2,p=1$..."
}
brute_force_protection {
max_attempts 5
lockout_duration "15m"
window "5m"
}
}
}Use Basic Auth only over HTTPS. Credentials travel in the Authorization header on every request.
Forcing download / MIME types#
Apache:
AddType application/octet-stream .pdf
<FilesMatch "\.pdf$">
ForceType application/octet-stream
</FilesMatch>Ferron maps extensions to MIME types with mime_type, and you can add a Content-Disposition header for a subtree to force download:
example.com {
root /var/www/html
mime_type ".pdf" "application/octet-stream"
match DOWNLOADS {
request.uri.path ~ r"^/downloads(?:/|$)"
}
if DOWNLOADS {
header +Content-Disposition "attachment"
}
}Browser caching (Expires / Cache-Control)#
Apache uses mod_expires:
<IfModule mod_expires.c>
ExpiresActive On
ExpiresByType image/png "access plus 1 month"
ExpiresDefault "access plus 1 week"
</IfModule>Ferron does not compute Expires from durations, but you can set Cache-Control directly and let the client derive expiry. Use file_cache_control for static files:
example.com {
root /var/www/html
file_cache_control "public, max-age=604800" # 1 week default
match ASSETS {
request.uri.path ~ r"^/assets(?:/|$)"
}
if ASSETS {
file_cache_control "public, max-age=2592000" # 30 days
}
}Hotlink protection#
Apache blocks off-site referrers:
RewriteEngine On
RewriteCond %{HTTP_REFERER} !^$
RewriteCond %{HTTP_REFERER} !^https://example\.com/ [NC]
RewriteRule \.(jpg|png|gif)$ - [F]In Ferron, match the referer header and return 403:
match hotlink {
request.header.referer !~ r"^https://example\.com/"
request.header.referer != ""
}
example.com {
root /var/www/html
if hotlink {
status 403 {
body "Hotlinking not allowed"
}
}
}Blocking bad user agents / bots#
Apache:
RewriteCond %{HTTP_USER_AGENT} (BadBot|EvilScraper) [NC]
RewriteRule ^ - [F]Ferron uses a match on user_agent and a 403 status:
match bad_bot {
request.header.user_agent ~ "(?i)badbot|evilscraper"
}
example.com {
if bad_bot {
status 403 {
body "Blocked"
}
}
}For silently dropping connections instead of returning a response, use abort in place of status 403.
Maintenance mode#
Apache takes the site down with:
RewriteEngine On
RewriteCond %{REMOTE_ADDR} !^203\.0\.113\.10
RewriteRule ^ - [R=503,L]Ferron returns a 503 for everyone except an allowed IP:
match not_maintainer {
remote.ip != "203.0.113.10"
}
example.com {
if not_maintainer {
status 503 {
body "Site under maintenance"
}
}
}Migration checklist#
- Install and start PHP-FPM. Confirm the socket (or TCP port) is reachable by the Ferron user.
- Create
ferron.confwith theroot,fcgi_php, and TLS settings for your domain. - Recreate each
.htaccessrule using the mapping above. Userewritefor routing,allow/blockfor ACLs,error_pagefor error documents,headerfor headers, andbasic_authfor password areas. - Move the front-controller rewrite (and any
index/directory_listingsettings) to the host block. - Validate the configuration:
ferron validate -c ferron.conf. - Run
ferron doctor -c ferron.confto catch TLS, redirect, and timeout best-practice issues. - Start Ferron and confirm:
https://example.com/loads the home page (PHP executed).- A clean URL (for example,
/sample-post) renders viaindex.php. http://redirects tohttps://(308).www.redirects to the canonical host (301) if configured.- Custom 404/50x pages appear for missing routes and backend errors.
- Protected areas require a password or allowed IP.
Keep the old Apache config until Ferron has served production traffic successfully. You can then roll back by repointing the service.
See also#
- PHP hosting: use FastCGI and CGI for PHP, plus troubleshooting.
- FastCGI support: configure the
fcgi/fcgi_phpdirectives and environment variables. - URL rewriting: see
rewritesyntax and the regex engine. - Access control: combine
allow/block,basic_auth, andauth_to. - Error pages: use
error_pageandintercept_errors. - Conditionals and variables: use
match/ifand available variables.