# How to lock down a self-hosted Matomo install without breaking tracking

> Self-hosting Matomo means the admin login sits behind nothing but a username and password, and on some servers files that should be private are downloadable in the browser. Matomo auto-protects its private directories but not the web root, and on Nginx it protects nothing at all. Here's the hardening that actually matters on Matomo 5, in priority order, without locking out your tracker or your own archiving.

Published: 2026-08-17
Categories: Matomo Self-Hosting
Canonical: https://martez.io/blog/harden-secure-matomo-installation

---

If you self-host Matomo, two things tend to surface around the same time. The admin login at `index.php` is reachable by anyone who knows the URL, with only a username and password in front of it. And files that feel like they should be private are downloadable straight from the browser: a stray `.sh` helper, a `README.md` buried in `node_modules`, leftover build artifacts. Neither is a bug. That's the default posture of a fresh install, and closing both gaps is on you, not on Matomo.

Fast path: keep `matomo.php`, `matomo.js`, the legacy `piwik.*` files, `js/container_*.js`, opt-out assets, and Heatmap configs public. Require authentication for everything else. Then switch archiving to real CLI, force HTTPS, verify the trusted host, enforce 2FA, turn on strong-password-on-change, and rotate stale tokens. The details below keep each exception testable.

<Callout type="tip" title="TL;DR">

A fresh Matomo self-host leaves the admin login exposed, and depending on your web server, leaves private files downloadable too. Matomo auto-protects its private directories on Apache and IIS but never the web root, and on Nginx it protects nothing. Require auth everywhere except an explicit public allow-list (`matomo.php`, `matomo.js`, `js/container_*.js`, opt-out, Heatmap configs), then handle archiving, `force_ssl`, trusted hosts, 2FA, and stale tokens. The one trap to plan for: Basic Auth breaks HTTP cron archiving, so move archiving to CLI with `./console core:archive`.

</Callout>

It's worth understanding why the install feels open before you start pasting config. During setup, Matomo writes protective files for private directories on Apache and IIS. Those files are real, but Apache still has to honor overrides, and Nginx ignores `.htaccess` outright. Matomo also does not lock the web root for you, because the tracker, Tag Manager containers, opt-out iframe, and some plugin endpoints must remain reachable by visitors.

## Confirm what's exposed before you change anything

Go to Administration → Diagnostic → System Check. It reports, among other things, whether private directories are reachable over HTTP. If it flags a directory as accessible, that's your starting point: the generated protection either did not write, is being ignored by the server, or has not been translated into Nginx rules. The same report runs from the CLI with `./console diagnostics:run`, which is handy for checking several installs at once.

Then test it yourself, because System Check can't see everything your web server exposes. In a browser or with curl, hit paths that should be off-limits:

```bash
curl -I https://your-matomo.example.com/node_modules/
curl -I https://your-matomo.example.com/CHANGELOG.md
curl -I https://your-matomo.example.com/config/config.ini.php
```

You want a `403`, `404`, or `401`, not a directory listing or file contents. If a private file downloads, fix the server layer first. Where that fix lives depends on the stack: on Apache or IIS, run `./console core:create-security-files` from the Matomo directory if the generated security files are missing; on Nginx, start from the maintained `matomo-org/matomo-nginx` configuration rather than relying on `.htaccess`; behind Docker or a proxy, track down which container or proxy actually serves Matomo before touching anything. Only once the private paths are sealed should you add admin auth with the public exceptions below.

## Restrict the admin, keep the tracker public

The goal is to require authentication for everything by default, then re-open the handful of endpoints that genuinely need to be public. Matomo's own security guidance calls out this public family:

| Path | Why it stays public | Expected result |
| --- | --- | --- |
| `/matomo.php`, `/matomo.js`, `/piwik.php`, `/piwik.js` | JavaScript tracker and legacy tracker URLs | Reachable without Basic Auth |
| `/js/container_*.js` | Matomo Tag Manager containers and preview containers | `200`, no auth prompt |
| `/index.php?module=CoreAdminHome&action=optOut` | Opt-out iframe | `200`, no auth prompt |
| `/plugins/CoreAdminHome/javascripts/optOut.js` | Opt-out JavaScript | `200`, no auth prompt |
| `/plugins/HeatmapSessionRecording/configs.php` | Heatmaps and Session Recording config bootstrap | Public if that plugin is enabled |
| `/favicon.ico` | Browser favicon request | Public |
| `/node_modules/`, `/config/config.ini.php`, `/tmp/` | Private application files | `403`, `404`, or auth prompt |

Run this matrix after every server edit. Replace the Tag Manager container name with a real generated filename from your install:

```bash
curl -I https://your-matomo.example.com/matomo.js
curl -I https://your-matomo.example.com/matomo.php
curl -I https://your-matomo.example.com/js/container_ABC123.js
curl -I "https://your-matomo.example.com/index.php?module=CoreAdminHome&action=optOut"
curl -I https://your-matomo.example.com/plugins/CoreAdminHome/javascripts/optOut.js
curl -I https://your-matomo.example.com/plugins/HeatmapSessionRecording/configs.php
curl -I https://your-matomo.example.com/node_modules/
```

### Apache

On Apache 2.4, the web-root `.htaccess` approach only works if Apache is allowed to read it. Confirm the vhost has `AllowOverride All`, or at least the override classes needed for auth and access control, and that the auth modules are loaded: `mod_auth_basic`, `mod_authn_file`, and `mod_authz_core`. Create the password file outside the public web root, for example with `htpasswd -c /secure/path/.htpasswd admin`. Then reload Apache and test one protected URL and one public tracker URL before you change any other hardening setting.

Use modern `Require` directives. `Allow`, `Deny`, and `Order` are deprecated in Apache 2.4 and depend on `mod_access_compat`; they are not the right basis for new configs.

```apache
# Lock down everything by default
<Files "*">
  AuthType Basic
  AuthName "Matomo"
  AuthBasicProvider file
  AuthUserFile "/secure/path/.htpasswd"
  Require valid-user
</Files>

# Re-open tracker, legacy tracker, Tag Manager, opt-out JS, and common public files
<FilesMatch "^(matomo|piwik)\.(php|js)$|^container_.*\.js$|^optOut\.js$|^favicon\.ico$|^robots\.txt$">
  Require all granted
</FilesMatch>

# Re-open the Heatmap and Session Recording config endpoint only
<Files "configs.php">
  <If "%{REQUEST_URI} =~ m#^/plugins/HeatmapSessionRecording/configs\.php$#">
    Require all granted
  </If>
</Files>

# Re-open the opt-out iframe, and nothing else under index.php
<Files "index.php">
  <If "%{QUERY_STRING} =~ /^module=CoreAdminHome&action=optOut(?!.*module=)(?!.*action=)/">
    Require all granted
  </If>
</Files>
```

If Matomo lives under a sub-path, update the `REQUEST_URI` expression for that path and re-run the curl checks. The opt-out block deserves the extra caution. The official endpoint is `index.php?module=CoreAdminHome&action=optOut`, and you have to keep it public so the iframe loads for visitors. The two negative lookaheads stop someone appending a second `module=` or `action=` to the query string to smuggle a different controller past your auth. Test that exact bypass on staging:

```bash
curl -I "https://your-matomo.example.com/index.php?module=CoreAdminHome&action=optOut"
curl -I "https://your-matomo.example.com/index.php?module=CoreAdminHome&action=optOut&module=UsersManager"
```

The first should load without auth. The second should not.

If you'd rather gate the UI by network than by password, use `Require ip 203.0.113.5` for the protected side instead of `Require valid-user`. That's the easier path when admins all sit behind a VPN or a known office IP.

### Nginx

On Nginx, do not translate the Apache block mentally. Start from the maintained `matomo-org/matomo-nginx` vhost, then add Basic Auth at the `server` or `/` level and turn it off only for the public endpoint family above. Nginx `location` and regex order decide which rule wins, so the curl matrix is part of the change.

One workable pattern is to put a `map` in the `http` context, outside the `server` block, and feed that value into `auth_basic`:

```nginx
map "$uri?$args" $matomo_basic_auth {
  default "Matomo";
  ~^/(matomo|piwik)\.(php|js)\?$ off;
  ~^/js/container_.*\.js\?$ off;
  ~^/plugins/CoreAdminHome/javascripts/optOut\.js\?$ off;
  ~^/plugins/HeatmapSessionRecording/configs\.php\?$ off;
  ~^/favicon\.ico\?$ off;
  ~^/index\.php\?module=CoreAdminHome&action=optOut$ off;
}

server {
  server_name your-matomo.example.com;
  root /var/www/matomo;

  auth_basic $matomo_basic_auth;
  auth_basic_user_file /etc/nginx/.htpasswd;

  location ~ ^/(index|matomo|piwik|js/index|plugins/HeatmapSessionRecording/configs)\.php$ {
    include snippets/fastcgi-php.conf;
    try_files $fastcgi_script_name =404;
    fastcgi_param HTTP_PROXY "";
    fastcgi_pass unix:/var/run/php/php8.3-fpm.sock;
  }

  location ~* ^.+\.php$ {
    deny all;
    return 403;
  }

  location / {
    try_files $uri $uri/ =404;
  }

  location = /plugins/CoreAdminHome/javascripts/optOut.js {
    try_files $uri =404;
  }

  location ~ \.(gif|ico|jpg|png|svg|js|css|htm|html|ttf|eot|woff|woff2)$ {
    try_files $uri =404;
  }

  location ~ ^/(config|tmp|core|lang|libs|vendor|plugins|misc|node_modules) {
    deny all;
    return 403;
  }
}
```

This is a skeleton, not a replacement for the full Matomo Nginx vhost. Keep the FastCGI socket, TLS, headers, cache rules, and deny rules from your real config. If your opt-out URL has extra benign query parameters, add that exact shape to the `map`; do not make every `index.php` request public. If your Matomo sits behind a reverse proxy, the headers that matter for auth matter for the proxy too. We covered that wiring in [why a reverse-proxied Matomo throws 502s](/blog/self-host-matomo-reverse-proxy-502).

<Callout type="tip" title="Rollback path">
  Keep an SSH session open while you test. If you lock yourself out on Apache, move the web-root `.htaccess` aside and leave Matomo's private-directory security files alone. On Nginx, comment the new `auth_basic` lines, run `nginx -t`, and reload.
</Callout>

## Don't lock out your own archiving

The first thing Basic Auth tends to break is HTTP-triggered cron archiving, which starts returning `401`s the moment you protect the root. The clean fix is to stop archiving over HTTP and run it through the CLI:

```bash
./console core:archive --url=https://your-matomo.example.com
```

Run that from the Matomo host as the web-server user or the dedicated cron user. `core:archive` normally avoids web-server auth when Matomo can launch CLI child processes. That nuance matters: if the OS or PHP runtime can't support CLI child processes, Matomo falls back to curl-based HTTP requests, and then Basic Auth can still bite. Watch the archiver output and server logs after the first protected run. If you see `401`s, check whether CLI child processes are supported; if Matomo has fallen back to HTTP, fix CLI support or allow the archiver path with credentials over HTTPS.

If you're still wiring archiving up, [setting up Matomo cron archiving](/blog/set-up-matomo-cron-archiving) walks through the whole job. If you genuinely must keep HTTP archiving, pass credentials only over HTTPS and prefer POSTing tokens where Matomo supports it. CLI is what we'd reach for every time.

## Force HTTPS and verify the host header

Two lines in `config/config.ini.php`, under `[General]`, close off a surprising amount of surface:

```ini
[General]
force_ssl = 1
trusted_hosts[] = "matomo.example.com"
```

`force_ssl = 1` redirects every `http://` request to its `https://` equivalent. Confirm HTTPS actually works first, or you'll lock yourself out. The trusted host check is enabled by default in current Matomo and the install hostname is set during setup, so treat this as a verification step: make sure every real public hostname appears as its own `trusted_hosts[]` line and remove stale ones. This protects against untrusted hostnames, including host-header injection risks, but it does not restrict visitors by IP or network. Use server-level rules for that.

## Lock down the accounts

Web-server hardening protects the perimeter. The accounts behind it are the other half.

Start with two-factor authentication. Set up 2FA on your own Super User account first, under Administration → Personal → Security, because Matomo won't let you enforce it org-wide until you have. Then, in the Two-Factor Authentication settings under General Settings, tick "Require two-factor authentication for everyone". One caveat that matters here: 2FA isn't checked on API requests authenticated by token. So enforcing it hardens the interactive login and does nothing for a leaked token, which is exactly why the audit step below isn't redundant.

Force strong passwords next. Under Administration → System → General Settings → Login, enable "Force strong passwords to be used". It requires at least 12 characters with mixed case, numbers, and symbols when a password is set or updated. It is not retroactive: existing weak passwords continue to work until users or a Super User change them. If compliance requires immediate rotation, reset those accounts rather than assuming the checkbox forced it.

Leave brute-force protection on. Matomo 5 ships it enabled by default: repeated failed logins temporarily block the offending IP. Check the Login settings and your config for the actual duration on your install instead of assuming a universal block window. The allowlist for IPs that should never be blocked lives in that same General Settings → Login panel, and you can review or clear active blocks under Administration → Diagnostic → Brute Force Log. Worth checking the allowlist before a flaky office connection locks you out.

## Audit users and tokens

The UI does not give a Super User a full token and password-age audit view. On self-hosted Matomo 5, query the database directly. Replace the `matomo_` prefix if your tables use a different one:

```sql
SELECT
  login,
  email,
  superuser_access,
  ts_password_modified,
  ts_last_seen
FROM matomo_user
ORDER BY login;

SELECT
  login,
  description,
  date_created,
  last_used,
  date_expired,
  secure_only,
  system_token,
  hash_algo
FROM matomo_user_token_auth
ORDER BY login, date_created;
```

`user_token_auth` stores token hashes, not the plain token value. In current Matomo 5 code those hashes are salted SHA-512 values in the `password` column. The useful audit fields are the owner, description, creation date, last-used date, expiry, whether it is a system token, and `secure_only`. A `secure_only` token must be supplied through a secure mechanism such as POST or an auth header; do not rely on URL parameters for those tokens. Rotate or expire stale tokens, and prefer short-lived or POST-only tokens where the workflow allows it.

The `user.ts_password_modified` field records when a password was last changed. The UI may not expose that as a global report, but the data is there for self-hosted installs. The community `UsersPasswordModified` plugin adds a Users Manager column if you need that view in the interface. If a query against these tables errors out instead of returning rows, our notes on [fixing common Matomo database errors](/blog/fix-matomo-database-errors) cover the usual causes.

If creating or deleting an API token hangs after submission, inspect application logs and mail logs before blaming Basic Auth. If the one-time token display was interrupted, delete and recreate that token; you cannot read the plain token value back from the database.

On Matomo Cloud, the web-server and directory hardening is handled for you and you have no database access, so the audit becomes account-layer only: enforce 2FA, force strong passwords, rotate tokens on a schedule, and ask support for a data dump if you need a formal one.

## What we'd actually do

If you run one install, the thirty minutes that matter most go like this: lock the web root with the Apache or Nginx pattern above, paste the public allow-list verbatim, switch archiving to CLI, set `force_ssl`, verify `trusted_hosts`, and enforce 2FA plus strong-password-on-change. Then prove four things: tracker returns without auth, Tag Manager container loads, opt-out iframe loads, and `./console core:archive` runs without `401`s. Save the config diff.

When something does break after you turn on Basic Auth, it's almost always one of four things, and the cause is usually obvious once you curl the right URL:

| What broke | Likely cause | Fix |
| --- | --- | --- |
| Tracker or Tag Manager stops recording | A public tracker endpoint is still behind auth | `curl -I` `matomo.js`, `matomo.php`, and the real `js/container_*.js` URL from outside the admin network; add only that endpoint to the allow-list and reload |
| Opt-out iframe prompts for a password | `index.php` was protected without the narrow `CoreAdminHome` opt-out exception | Allow only `module=CoreAdminHome&action=optOut` and reject duplicate `module`/`action` parameters |
| Heatmaps or session recordings stop loading | `plugins/HeatmapSessionRecording/configs.php` is blocked | Add the Heatmap config endpoint to the allow-list when that plugin is enabled |
| Cron archiving starts returning `401` | The job still uses an HTTP path, or `core:archive` fell back to curl-based HTTP | Run `core:archive` through CLI child processes, or configure the fallback path deliberately |

If you run several installs, put System Check (`./console diagnostics:run`) into a scheduled job and re-run it after every upgrade. Upgrades occasionally regenerate protected paths, and a check that runs itself is the only kind that survives the third busy week. Keep tracking on its own public endpoint while everything else stays gated. It's the same separation we lean on when we [run the tracker server-side through the HTTP Tracking API](/blog/matomo-http-tracking-api-server-side).

Whichever route you take, save the evidence: the System Check result, a `403` or `404` for `/node_modules/` and `/config/config.ini.php`, successful public responses for `matomo.php`, `matomo.js`, the Tag Manager container, opt-out, and Heatmap configs if enabled, and the first clean `core:archive` run after Basic Auth. A short hardening note now is what you'll thank yourself for the next time an upgrade quietly re-opens a path.

[Martez](/?utm_source=martez&utm_medium=blog&utm_campaign=harden-secure-matomo-installation) connects Matomo with Meta Ads and Google Ads so ROAS, CLV, and attribution sit next to your web analytics instead of in a separate spreadsheet. It's in private beta. [Join the waitlist](/signup?utm_source=martez&utm_medium=blog&utm_campaign=harden-secure-matomo-installation) if that's relevant to you.

Self-hosting means you own the data and you own the lock. Finish by proving the public endpoints still work and the private paths do not.
