An .htaccess file is a per-directory configuration file that Apache reads on every request, so you can change redirects, access rules and headers for a directory without editing the main server configuration or reloading Apache. It is the usual way to configure sites on shared hosting and the mechanism many PHP applications (WordPress, Laravel, Drupal) ship with. In this tutorial you will enable .htaccess support in Apache on Ubuntu 24.04 and use it for HTTPS redirects, clean URLs, access control, security headers, caching and custom error pages.

Prerequisites

To follow this tutorial you need:

  • A server running Ubuntu 24.04 LTS, for example a CubePath VPS, with a non-root user that has sudo privileges.
  • Apache installed and serving a site from /var/www/html or a virtual host of your own.
  • A domain name (your_domain in the examples) pointing to your server if you want to test the HTTPS and www redirects, plus a TLS certificate (for example from Let's Encrypt with Certbot).

If Apache is not installed yet, install it and open HTTP and HTTPS in the firewall:

sudo apt update
sudo apt install apache2
sudo ufw allow "Apache Full"

Step 1 - Allowing .htaccess overrides

By default Ubuntu's Apache ignores .htaccess files under /var/www because the <Directory /var/www/> block sets AllowOverride None. Open the main configuration file:

sudo nano /etc/apache2/apache2.conf

Find the block for /var/www/ and change AllowOverride None to AllowOverride All:

<Directory /var/www/>
        Options FollowSymLinks
        AllowOverride All
        Require all granted
</Directory>

The block also removes Indexes from Options so Apache does not list directory contents when there is no index file. If you only want to allow .htaccess for one site, add an equivalent <Directory /var/www/your_domain> block inside that site's virtual host instead and leave the global block untouched.

AllowOverride All lets .htaccess files use any directive allowed in that context. If you want to be stricter, list only the groups you need, for example AllowOverride FileInfo AuthConfig Limit Indexes.

Check the syntax and reload Apache:

sudo apache2ctl configtest
sudo systemctl reload apache2
Syntax OK

To confirm Apache now reads .htaccess, create a file containing an invalid directive and request the site:

echo "ThisIsNotADirective" | sudo tee /var/www/html/.htaccess
curl -I http://localhost/
HTTP/1.1 500 Internal Server Error

A 500 error means Apache parsed the file. Remove the test file before continuing:

sudo rm /var/www/html/.htaccess

Step 2 - Enabling the required modules

Most .htaccess recipes depend on modules that are not enabled by default on Ubuntu. Enable mod_rewrite for URL rewriting, mod_headers for response headers and mod_expires for cache headers:

sudo a2enmod rewrite headers expires
sudo systemctl restart apache2

Verify they are loaded:

sudo apache2ctl -M | grep -E 'rewrite|headers|expires'
 expires_module (shared)
 headers_module (shared)
 rewrite_module (shared)

mod_deflate, which compresses text responses, is already enabled on Ubuntu with sensible defaults for HTML, CSS, JavaScript, XML and JSON, so you do not need to configure it in .htaccess.

Step 3 - Redirecting to HTTPS and a canonical host name

Create or edit the .htaccess file in your document root:

sudo nano /var/www/html/.htaccess

The following rules send every HTTP request to HTTPS, and every request for www.your_domain to your_domain, each with a single permanent (301) redirect:

RewriteEngine On

# Redirect HTTP to HTTPS
RewriteCond %{HTTPS} off
RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]

# Redirect www to the bare domain
RewriteCond %{HTTP_HOST} ^www\.(.+)$ [NC]
RewriteRule ^ https://%1%{REQUEST_URI} [L,R=301]

%{HTTPS} is off for plain HTTP requests, and %1 refers to the part of the host name captured by (.+) in the preceding RewriteCond. Changes to .htaccess take effect immediately, no reload is needed.

To redirect a single page that moved, use the simpler Redirect directive from mod_alias:

Redirect 301 /old-page.html /new-page.html

Check the redirects with curl:

curl -I http://your_domain/
curl -I https://www.your_domain/
HTTP/1.1 301 Moved Permanently
Location: https://your_domain/

Step 4 - Creating clean URLs

Two patterns cover most needs. The first hides the .php extension, so /about serves about.php when that file exists:

RewriteEngine On

RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_FILENAME}.php -f
RewriteRule ^(.+?)/?$ $1.php [L]

The conditions skip requests for files and directories that exist, and only rewrite when a matching .php file is present.

The second is a front controller: every request that does not match a real file or directory is handed to index.php, which is what frameworks such as Laravel and WordPress expect. mod_dir provides a one-line directive for this:

FallbackResource /index.php

Use one of the two patterns, not both in the same directory. To test the first one, create a small PHP file (this requires PHP and libapache2-mod-php) and request it without the extension:

echo '<?php echo "about page\n";' | sudo tee /var/www/html/about.php
curl https://your_domain/about
about page

Step 5 - Restricting access

Password protecting a directory

Install apache2-utils, which provides htpasswd, and create a password file outside the document root. Replace your_user with the login name you want:

sudo apt install apache2-utils
sudo htpasswd -c /etc/apache2/.htpasswd your_user

You will be asked to type the password twice. Omit -c when adding more users later, because -c overwrites the file. Make the file readable only by root and the Apache group:

sudo chown root:www-data /etc/apache2/.htpasswd
sudo chmod 640 /etc/apache2/.htpasswd

Then create a .htaccess file in the directory you want to protect, for example /var/www/html/admin/.htaccess:

AuthType Basic
AuthName "Restricted area"
AuthUserFile /etc/apache2/.htpasswd
Require valid-user

Basic authentication sends credentials in every request, so only use it over HTTPS.

Allowing only specific IP addresses

Apache 2.4 uses Require directives for access control (the old Order, Allow and Deny directives are deprecated). To allow only your office network and one additional address into a directory:

<RequireAny>
    Require ip 203.0.113.0/24
    Require ip 198.51.100.7
</RequireAny>

Replace the addresses with your own. Everyone else receives 403 Forbidden.

Verify both rules with curl:

curl -I https://your_domain/admin/
curl -I -u your_user https://your_domain/admin/

The first request returns 401 Unauthorized (or 403 Forbidden for the IP rule), and the second returns 200 OK after you enter the password.

Step 6 - Hardening the site

Add the following block to the .htaccess in your document root. It blocks access to hidden files such as .env or .git, and sends common security headers:

# Deny access to hidden files (.env, .git/config, .htpasswd...)
<FilesMatch "^\.">
    Require all denied
</FilesMatch>
RedirectMatch 404 /\.git

# Security headers
Header always set X-Content-Type-Options "nosniff"
Header always set X-Frame-Options "SAMEORIGIN"
Header always set Referrer-Policy "strict-origin-when-cross-origin"

FilesMatch matches file names, so /.well-known/acme-challenge/ used by Let's Encrypt keeps working. The RedirectMatch line hides the whole .git directory with a 404.

If you did not remove Indexes in Step 1, you can also disable directory listings per directory:

Options -Indexes

Check that a hidden file is blocked and the headers are sent:

curl -I https://your_domain/.env
curl -sI https://your_domain/ | grep -iE 'x-content-type|x-frame|referrer'
HTTP/1.1 403 Forbidden
...
X-Content-Type-Options: nosniff
X-Frame-Options: SAMEORIGIN
Referrer-Policy: strict-origin-when-cross-origin

Step 7 - Setting browser cache headers

mod_expires adds Cache-Control: max-age and Expires headers so browsers reuse static files instead of downloading them on every visit. Add this to the document root .htaccess:

ExpiresActive On
ExpiresByType image/webp "access plus 1 year"
ExpiresByType image/png "access plus 1 year"
ExpiresByType image/jpeg "access plus 1 year"
ExpiresByType image/svg+xml "access plus 1 year"
ExpiresByType font/woff2 "access plus 1 year"
ExpiresByType text/css "access plus 1 month"
ExpiresByType text/javascript "access plus 1 month"
ExpiresByType application/javascript "access plus 1 month"

Long cache times are only safe for files whose name changes when their content changes (for example app.3f9a1c.css). Leave HTML out so visitors always receive the latest pages.

Verify on an existing stylesheet:

curl -sI https://your_domain/css/style.css | grep -iE 'cache-control|expires'
Cache-Control: max-age=2592000
Expires: Sun, 25 Oct 2026 10:00:00 GMT

You can confirm compression from mod_deflate at the same time:

curl -sI -H "Accept-Encoding: gzip" https://your_domain/css/style.css | grep -i content-encoding
Content-Encoding: gzip

Step 8 - Custom error pages and maintenance mode

Point Apache to your own error pages with ErrorDocument. The paths are relative to the document root:

ErrorDocument 404 /404.html
ErrorDocument 403 /403.html

For planned maintenance, you can return 503 Service Unavailable to everyone except your own IP address. Create /var/www/html/maintenance.html, then add:

ErrorDocument 503 /maintenance.html

RewriteEngine On
RewriteCond %{REMOTE_ADDR} !^203\.0\.113\.10$
RewriteCond %{REQUEST_URI} !^/maintenance\.html$
RewriteRule ^ - [R=503,L]

Replace 203\.0\.113\.10 with your IP address (dots escaped). A 503 status tells search engines the outage is temporary. Comment out or delete the rules when maintenance is finished.

Test from a machine that is not whitelisted:

curl -I https://your_domain/
HTTP/1.1 503 Service Unavailable

Troubleshooting

500 Internal Server Error after editing .htaccess. Apache writes the exact cause to the error log:

sudo tail -n 20 /var/log/apache2/error.log
/var/www/html/.htaccess: Invalid command 'Header', perhaps misspelled or defined by a module not included in the server configuration

This message means the module that provides the directive is not enabled. Enable it with a2enmod (Step 2). Note that apache2ctl configtest does not check .htaccess files, so the error log is the place to look.

Rules are ignored. AllowOverride is still None for that directory, or another <Directory> block for a more specific path overrides it. Check with grep -R AllowOverride /etc/apache2/ and repeat the test from Step 1.

Redirect loop (ERR_TOO_MANY_REDIRECTS). Usually caused by the HTTPS rule behind a TLS-terminating proxy (see the warning in Step 3), or by an application that also redirects to a different host name than your www rule.

Rewrite rules do not match. In .htaccess the path matched by RewriteRule has no leading slash: use ^blog/(.*)$, not ^/blog/(.*)$. To see how Apache processes a rule, temporarily add LogLevel alert rewrite:trace3 to the virtual host (not .htaccess), reload Apache and read /var/log/apache2/error.log.

Conclusion

You enabled .htaccess overrides in Apache on Ubuntu 24.04 and used them for redirects, clean URLs, password and IP restrictions, security headers, caching and maintenance pages. As next steps, move stable rules into your virtual host configuration and set AllowOverride None where you no longer need .htaccess, add a Let's Encrypt certificate with Certbot if you have not already, and review the rest of your Apache configuration with a hardening checklist.