Skip to content

Apache & .htaccess Configuration

Total CMS routes every request that does not match a real file or directory through its front controller (public/index.php). On Apache, that routing is expressed as mod_rewrite rules in .htaccess files. LiteSpeed reads .htaccess the same way Apache does — this page applies to both.

If you missed the rewrite snippets on the Setup Wizard’s Server Configuration step, the rules below are the same content the wizard generates. The fastest way to view the exact snippet for your install — including the right URL prefix for sub-directory layouts — is to revisit the wizard, but for a fresh standalone install the standard rules in this guide will do.

A Composer install (composer create-project totalcms/totalcms) drops these .htaccess files into your project automatically:

FilePurpose
public/.htaccessFront-controller rewrite — routes every non-file/non-directory request through index.php.
tcms-data/.htaccessHard-denies direct access to the data directory so collections, uploads, and secrets are never served by the web server.
.htaccess (project root)Only present when public/ is NOT your docroot. Redirects / into public/ so subpath installs work without changing the docroot.

Zip installs and the Stacks plugin layout ship the same files. If you are installing from a custom source and don’t see them, the snippets below are the canonical content.

You only need the project-root .htaccess when your docroot is one level above public/. If you point Apache’s DocumentRoot directly at public/ (the recommended layout — see Filesystem), only the two files inside public/ and tcms-data/ matter.

The Setup Wizard’s Server Configuration step inspects your environment and generates the exact rewrite rules you need, including:

  • The right URL prefix when public/ lives below your docroot.
  • An explicit deny rule for tcms-data/ when it sits inside your docroot.

If you skipped the wizard or want to revisit it, the path is /setup/server-config on a fresh install. After installation, the same rules are documented below.

Standalone Install — public/ as the Docroot

Section titled “Standalone Install — public/ as the Docroot”

This is the recommended layout: point Apache’s DocumentRoot at public/ and only the front-controller rule is needed.

public/.htaccess:

# Redirect to front controller
RewriteEngine On
# RewriteBase /
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^ index.php [QSA,L]

tcms-data/.htaccess:

# Deny direct access to all files and folders in tcms-data
# This protects sensitive data including API keys, collections, and user data
<IfModule mod_authz_core.c>
Require all denied
</IfModule>
<IfModule !mod_authz_core.c>
Order deny,allow
Deny from all
</IfModule>

The mod_authz_core block is the Apache 2.4+ syntax; the fallback covers older Apache 2.2 hosts.

Subdirectory Install — Docroot Above public/

Section titled “Subdirectory Install — Docroot Above public/”

If your docroot is the project root (so URLs look like https://example.com/index.php but the front controller is actually at public/index.php), add this .htaccess at the project root to bridge them:

RewriteEngine On
# Don't rewrite if "public/" is already in the path
RewriteCond %{REQUEST_URI} !public/
RewriteRule ^$ public/ [L]
RewriteCond %{REQUEST_URI} !public/
RewriteRule ^(.*)$ public/$1 [L]

public/.htaccess is still required — the project-root file rewrites the URL into public/, then the rules in public/.htaccess take over and route the request through index.php.

When Total CMS is installed as a Stacks plugin at /rw_common/plugins/stacks/tcms/, the document root sits well above public/. Add the following to your docroot .htaccess to bridge the gap:

RewriteEngine On
# Redirect all other requests to /rw_common/plugins/stacks/tcms/public
RewriteCond %{REQUEST_URI} ^/rw_common/plugins/stacks/tcms/
RewriteCond %{REQUEST_URI} !^/rw_common/plugins/stacks/tcms/public/
RewriteRule ^(.*)$ rw_common/plugins/stacks/tcms/public/$1 [L]
# Process requests through index.php if not a file or directory
RewriteCond %{REQUEST_URI} ^/rw_common/plugins/stacks/tcms/public/
RewriteCond %{DOCUMENT_ROOT}%{REQUEST_URI} !-f
RewriteCond %{DOCUMENT_ROOT}%{REQUEST_URI} !-d
RewriteRule ^rw_common/plugins/stacks/tcms/public/.*$ rw_common/plugins/stacks/tcms/public/index.php [QSA,L]

Adjust the install path if you mounted the plugin somewhere other than /rw_common/plugins/stacks/tcms/.

Root-level Endpoints (MCP, OAuth, .well-known/*)

Section titled “Root-level Endpoints (MCP, OAuth, .well-known/*)”

The rules above only route requests inside /rw_common/plugins/stacks/tcms/. Several Total CMS endpoints live at the site root and won’t be reached without additional rewrites:

EndpointUsed by
/.well-known/mcp.jsonAI-agent MCP discovery
/mcpThe MCP endpoint itself (JSON-RPC + SSE)
/.well-known/oauth-authorization-serverOAuth 2.1 discovery (RFC 8414) — Claude/Cursor fetch this first
/.well-known/jwks.jsonJWK Set for access-token verification (RFC 7517)
/oauth/authorize, /oauth/token, /oauth/register, /oauth/revokeOAuth authorization, token exchange, dynamic client registration, revocation

Without these wired up, MCP can still work with API-key auth (X-API-Key header), but Claude/Cursor’s “connect to my CMS” flows — which depend on OAuth dynamic registration — will fail. Pick one of the two options below.

Section titled “Option 1 — Targeted rewrites (recommended default)”

Route only the specific T3 root-level endpoints. Add above the two existing Stacks rules in your docroot .htaccess:

# MCP discovery + endpoint
RewriteRule ^\.well-known/mcp\.json$ rw_common/plugins/stacks/tcms/public/index.php [QSA,L]
RewriteRule ^mcp(/.*)?$ rw_common/plugins/stacks/tcms/public/index.php [QSA,L]
# OAuth discovery + endpoints
RewriteRule ^\.well-known/oauth-authorization-server$ rw_common/plugins/stacks/tcms/public/index.php [QSA,L]
RewriteRule ^\.well-known/jwks\.json$ rw_common/plugins/stacks/tcms/public/index.php [QSA,L]
RewriteRule ^oauth/(authorize|token|register|revoke)$ rw_common/plugins/stacks/tcms/public/index.php [QSA,L]

This preserves the host site’s 404 behavior — any URL not on this list and not a real file still falls through to Apache’s ErrorDocument 404 (or the host’s custom 404 page).

Option 2 — Catch-all (anything-not-on-disk routes through T3)

Section titled “Option 2 — Catch-all (anything-not-on-disk routes through T3)”

If you want Total CMS to own every unmatched URL at the site root — useful when T3 is the primary content layer and Stacks is just hosting it — append this after the two existing Stacks rules:

# Route everything that isn't a real file or directory through Total CMS
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^ rw_common/plugins/stacks/tcms/public/index.php [QSA,L]

Trade-off: typos and missing pages anywhere on the site now hit Total CMS’s 404 instead of the host’s. Stacks-built pages survive (they exist on disk as /about/index.html directories), and any future root-level T3 endpoint works automatically without touching .htaccess again. If your Stacks site has a hand-styled 404 page you want to keep, use Option 1 instead.

Both options are compatible with the common customizations below — HTTPS redirect, security headers, compression, etc.

The snippets below are optional. Add them to public/.htaccess (or your docroot .htaccess) to harden, accelerate, or shape your install. Each block is independent — pick the ones you need.

Redirect every request to HTTPS. Run after RewriteEngine On, before the front-controller rule:

# Force HTTPS
RewriteCond %{HTTPS} off
RewriteRule ^(.*)$ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]

If you are behind a proxy or load balancer that terminates TLS, swap the condition for the forwarded-protocol header:

RewriteCond %{HTTP:X-Forwarded-Proto} !https
RewriteRule ^(.*)$ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]

Canonicalize the host name. Force www:

RewriteCond %{HTTP_HOST} ^example\.com [NC]
RewriteRule ^(.*)$ https://www.example.com/$1 [L,R=301]

Strip www:

RewriteCond %{HTTP_HOST} ^www\.example\.com [NC]
RewriteRule ^(.*)$ https://example.com/$1 [L,R=301]

Defense-in-depth headers on every response:

<IfModule mod_headers.c>
Header set X-Content-Type-Options "nosniff"
Header set X-Frame-Options "SAMEORIGIN"
Header set Referrer-Policy "strict-origin-when-cross-origin"
Header set Permissions-Policy "geolocation=(), microphone=(), camera=()"
# Only enable HSTS once you're confident HTTPS is fully working.
# Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains"
</IfModule>

Total CMS already sets a Content Security Policy via middleware — don’t add a Content-Security-Policy header here unless you intend to override it.

Compress text-based responses with mod_deflate:

<IfModule mod_deflate.c>
AddOutputFilterByType DEFLATE text/html text/plain text/css text/javascript
AddOutputFilterByType DEFLATE application/javascript application/json application/xml
AddOutputFilterByType DEFLATE image/svg+xml
</IfModule>

Far-future cache headers on hashed assets:

<IfModule mod_expires.c>
ExpiresActive On
ExpiresByType image/jpeg "access plus 1 year"
ExpiresByType image/png "access plus 1 year"
ExpiresByType image/webp "access plus 1 year"
ExpiresByType image/svg+xml "access plus 1 month"
ExpiresByType text/css "access plus 1 month"
ExpiresByType application/javascript "access plus 1 month"
ExpiresByType font/woff2 "access plus 1 year"
</IfModule>

Vite-built assets carry content hashes in their filenames, so they can safely use immutable caching:

<IfModule mod_headers.c>
<FilesMatch "\.([0-9a-f]{8,})\.(js|css|woff2)$">
Header set Cache-Control "public, max-age=31536000, immutable"
</FilesMatch>
</IfModule>

Belt-and-braces protection for any other dotfiles in your tree (.env, .git, etc.):

<FilesMatch "^\.">
Order Allow,Deny
Deny from all
</FilesMatch>

Note that tcms-data/.htaccess already blocks tcms-data/ entirely. This rule covers stray dotfiles elsewhere.

If you ever serve a directory that has no index.php or index.html, this prevents Apache from listing its contents:

Options -Indexes
ServerSignature Off

The public/.htaccess shipped with Total CMS contains a commented hint for session lifetime:

# Session Configuration
# Set session timeout to 24 hours (86400 seconds) for non-persistent logins
# php_value session.gc_maxlifetime 86400

Uncomment it if your host runs PHP as an Apache module (mod_php). It will have no effect under PHP-FPM or PHP-CGI — those configurations ignore php_value directives in .htaccess. For PHP-FPM, set the value in your pool’s www.conf instead:

php_admin_value[session.gc_maxlifetime] = 86400

Total CMS’s “Keep me logged in” persistent sessions are handled separately from session.gc_maxlifetime and aren’t affected by this setting.

The rewrite rules need mod_rewrite enabled. The header and caching customizations rely on additional modules:

ModuleUsed by
mod_rewriteFront-controller routing (required)
mod_headersSecurity headers, immutable asset caching
mod_deflateResponse compression
mod_expiresFar-future cache headers
mod_authz_coreModern Require all denied syntax in tcms-data/.htaccess

On Debian/Ubuntu, enable them with a2enmod rewrite headers deflate expires and restart Apache.

The vhost (or virtual host configuration) for the site must also include AllowOverride All (or at minimum AllowOverride FileInfo Limit AuthConfig) for the .htaccess files to take effect:

<Directory /var/www/totalcms/public>
AllowOverride All
Require all granted
</Directory>

If .htaccess rules appear to be ignored, this is the first thing to check.

After installing or editing .htaccess files:

  1. Reload Apache: systemctl reload apache2 (Debian/Ubuntu) or apachectl graceful (other distros).
  2. Visit your Total CMS admin URL — you should land on the login page or the wizard.
  3. Request a route that doesn’t map to a file, e.g. /sitemap. If you get a 404 from Apache (not Total CMS), the rewrite rule isn’t firing — check AllowOverride in the vhost and that mod_rewrite is loaded.
  4. Try to fetch a tcms-data/ file directly (e.g. https://example.com/tcms-data/collections/index.json). Apache should respond with 403. If you see the file content, the tcms-data/.htaccess rule isn’t being applied — again, check AllowOverride.

500 Internal Server Error after editing .htaccess. Apache rejects malformed .htaccess files with a 500. Check error.log — the failing line number is in the message. Common culprits: a stray <IfModule> without a matching </IfModule>, or a Header directive when mod_headers isn’t loaded.

Rules appear to be ignored. AllowOverride None in the vhost disables .htaccess processing site-wide. Set it to All for the directory containing your Total CMS files.

404 on /admin or other Total CMS URLs. The front-controller rewrite isn’t firing. Confirm mod_rewrite is loaded (apachectl -M | grep rewrite) and that the .htaccess files are present and readable.

Static assets return 404. The “pass through real files” guard (RewriteCond %{REQUEST_FILENAME} -f) should let real files through untouched. If it isn’t, check that the request path matches a real file relative to the directory containing the .htaccess — symlinks across docroots can break the check.