Nginx Configuration

Nginx does not read .htaccess files, so the rewrite rules that ship inside public/.htaccess and tcms-data/.htaccess have to be expressed in your server block instead. This page covers the equivalents — front-controller routing for public/index.php, an explicit deny rule for tcms-data/, and PHP-FPM hand-off.

If you missed the snippets on the Setup Wizard's Server Configuration step, the configuration below is the same content the wizard generates.

How Total CMS Routing Works on Nginx

Two things have to happen for a Total CMS site to serve correctly:

  1. Front-controller routing — Any URL that doesn't match a real file or directory under public/ is passed to public/index.php. That's how /admin, /api/..., /sitemap, and your Site Builder pages all reach the framework.
  2. Data protectiontcms-data/ is the flat-file store for collections, uploads, secrets, and runtime state. It must never be served directly. The simplest defense is to keep tcms-data/ outside your docroot. If it has to live under the docroot, deny it explicitly.

The two snippets below cover both concerns.

The Setup Wizard Snippet

The Setup Wizard's Server Configuration step (/setup/server-config on a fresh install) inspects your environment and generates the exact rules you need, including the URL prefix when public/ lives below your docroot. If you skipped the wizard, the snippets in this guide are the canonical content.

Point Nginx's root directive at public/. This is the cleanest setup: no path-stripping, no docroot bridging, and tcms-data/ is naturally outside the docroot.

server {
    listen 80;
    server_name example.com;
    root /var/www/totalcms/public;
    index index.php;

    # Front-controller routing — pass anything that isn't a real file
    # or directory to public/index.php.
    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }

    # PHP-FPM hand-off
    location ~ \.php$ {
        fastcgi_pass unix:/var/run/php/php-fpm.sock;
        fastcgi_index index.php;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        include fastcgi_params;
    }

    # Deny dotfiles (.env, .git, .htaccess, etc.)
    location ~ /\. {
        deny all;
    }
}

When public/ is the docroot, you do not need a location ~* /tcms-data/ block — the directory sits one level above the docroot and is unreachable by URL.

When tcms-data/ Lives Under the Docroot

Some hosting setups can't move tcms-data/ outside the served tree. In that case add an explicit deny rule for it:

# Block direct access to tcms-data
location ~* /tcms-data/ {
    deny all;
    return 403;
}

Place this before the location / block so it matches first.

Subdirectory Install — Docroot Above public/

If you can't change the docroot — for example, when Total CMS is mounted inside an existing site — Nginx needs to route requests through public/index.php while exposing them at a URL prefix. The pattern:

server {
    listen 80;
    server_name example.com;
    root /var/www/example.com;
    index index.php;

    # Block direct access to tcms-data
    location ~* /tcms-data/ {
        deny all;
        return 403;
    }

    # Total CMS mounted at /cms
    location /cms/ {
        try_files $uri $uri/ /cms/public/$uri /cms/public/index.php?$query_string;
    }

    # PHP-FPM for Total CMS PHP scripts
    location ~ /cms/.*\.php$ {
        fastcgi_pass unix:/var/run/php/php-fpm.sock;
        fastcgi_index index.php;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        include fastcgi_params;
    }
}

Adjust /cms/ to match the URL prefix where your install lives.

Stacks Plugin Install

When Total CMS is installed as a Stacks plugin at /rw_common/plugins/stacks/tcms/, the configuration follows the subdirectory pattern with that path:

# Block direct access to tcms-data
location ~* /tcms-data/ {
    deny all;
    return 403;
}

# Route requests into the public/ subdirectory and use the front controller
location /rw_common/plugins/stacks/tcms/ {
    try_files $uri $uri/ /rw_common/plugins/stacks/tcms/public/$uri /rw_common/plugins/stacks/tcms/public/index.php?$query_string;
}

# PHP-FPM for Total CMS
location ~ /rw_common/plugins/stacks/tcms/.*\.php$ {
    fastcgi_pass unix:/var/run/php/php-fpm.sock;
    fastcgi_index index.php;
    fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
    include fastcgi_params;
}

PHP-FPM Socket

The fastcgi_pass directive must point to your PHP-FPM socket or TCP address. Common values:

Distribution Socket Path
Ubuntu/Debian unix:/var/run/php/php8.2-fpm.sock
CentOS/RHEL unix:/var/run/php-fpm/www.sock
macOS (Homebrew) unix:/opt/homebrew/var/run/php-fpm.sock
TCP (any) 127.0.0.1:9000

Check your PHP-FPM pool configuration (www.conf) for the exact listen value.

Session Configuration

On Apache with mod_php, Total CMS can set session lifetime via .htaccess. On Nginx + PHP-FPM (the same is true for any PHP-FPM install, including Apache), php_value directives in .htaccess are ignored. Configure session lifetime in your PHP-FPM pool (www.conf) or php.ini instead:

; Set session timeout to 24 hours (matches Total CMS default)
php_admin_value[session.gc_maxlifetime] = 86400

Total CMS's "Keep me logged in" persistent sessions are handled separately and aren't affected by this setting.

Optional Hardening & Performance

HTTPS Redirect

Redirect HTTP to HTTPS at the server-block level rather than inside location /:

server {
    listen 80;
    server_name example.com;
    return 301 https://$host$request_uri;
}

Security Headers

add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
# Only enable HSTS once HTTPS is fully working.
# add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;

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

Compression

Enable gzip globally (typically in nginx.conf) or per server block:

gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/javascript application/javascript application/json application/xml image/svg+xml;

Static Asset Caching

Far-future caching for hashed Vite output:

location ~* \.([0-9a-f]{8,})\.(js|css|woff2)$ {
    expires 1y;
    add_header Cache-Control "public, immutable";
    try_files $uri =404;
}

Verifying Your Configuration

After editing the server block:

  1. Test the configuration: nginx -t
  2. Reload Nginx: systemctl reload nginx
  3. Navigate to your Total CMS admin URL. If you see a 404, double-check the try_files directive — it must fall back to index.php.
  4. Request a route that doesn't map to a file (/sitemap, /admin/login). If Nginx 404s instead of Total CMS, the front-controller rule isn't firing.
  5. Try to fetch a tcms-data/ file directly. You should get a 403 (or a 404 if the directory sits outside the docroot). If you can read the file, the deny rule isn't being applied — check that the block sits before location /.
  6. If you see a 502 Bad Gateway, verify your PHP-FPM socket path is correct and that PHP-FPM is running.