Why your self-hosted Matomo returns a 502 the moment it sits behind a reverse proxy

Matomo loads fine on its direct port, then throws a 502 Bad Gateway the second Caddy or Nginx is in front of it. The proxy log says 'connection refused', and that one word tells you exactly what's wrong: your proxy is dialing the host-published port from inside the Docker network, where it doesn't exist. Here's the fix, plus how to size the box Matomo runs on.

If you hit Matomo's port directly and it loads, then put Caddy or Nginx in front of it and get a 502 Bad Gateway, the proxy is doing its job. It reached out to your Matomo container and got nothing back. The tell is in the proxy log:

text
dial tcp 192.168.x.x:8080: connect: connection refused

connection refused is not a timeout, and it's not a DNS failure. The proxy found the container's network address fine. It dialed a port, and nothing was listening there to answer.

TL;DR

A 502 here almost always means the proxy is dialing the host-published port (app:8080) from inside the Docker network, where nothing is listening. For the default Apache Matomo image, point the proxy upstream at app:80 instead and reload it. The FPM image is the exception: there's no HTTP server in that container, so you proxy to a separate web container on port 80, and a /matomo sub-path needs its own forwarded-URI handling on top.

The published port is not the container port

Here's the compose snippet that produces it:

yaml
app:
  image: matomo
  ports:
    - 8080:80

8080:80 publishes the container's internal port 80 to 8080 on the host. The official matomo image runs Apache, and Apache listens on port 80 inside the container. The 8080 is a host-side mapping only. It exists on your VM's network interface so you can reach Matomo from a browser during setup. It doesn't exist inside the Docker network the containers use to talk to each other. Docker's own Compose docs make the same distinction: service-to-service traffic uses the container port, while the host port is for access from outside the Docker network.

When your reverse proxy is itself a container on that same network, it reaches Matomo by service name plus the container's internal port, never the host-published port. Docker's embedded DNS resolves app to the container's IP, and from there you dial whatever port the process is actually bound to. That's 80, not 8080.

Reverse proxy upstream
Wrong
# Caddy
reverse_proxy app:8080

# Nginx
proxy_pass http://app:8080;
Right
# Caddy
reverse_proxy app:80

# Nginx
proxy_pass http://app:80;

Switch the upstream to app:80 and the 502 clears. The host mapping (8080:80) can stay or go; it has nothing to do with traffic between containers. If anything, drop it once the proxy works, since you no longer want Matomo reachable on a raw HTTP port.

  1. 1

    Browser

  2. HTTPS
    2

    Host :443

    host :8080Maps to container :80, on the VM's interface only
  3. 3

    Caddy or Nginx container

    app:8080No listener: connection refused
  4. app:80
    4

    Matomo Apache container :80

Inside the Docker network the proxy dials the container port app:80. The host-published 8080 only exists on the VM's interface, so app:8080 has no listener.

The FPM image is a different animal

Apache image vs FPM image

The fix above assumes the Apache image. If you're running a matomo:*-fpm variant, there's no HTTP server inside that container at all. It speaks FastCGI on port 9000. Use a web container like the official Nginx example: Nginx serves /var/www/html and forwards PHP to app:9000 with fastcgi_pass. Your public reverse proxy should target that Nginx web container on port 80, not the FPM container.

Two gotchas that waste an afternoon

  1. Caddy labels versus a mounted Caddyfile. Plenty of compose files float around with caddy.reverse_proxy labels on the Matomo service. Those labels only do something with the lucaslorentz/caddy-docker-proxy image, which reads container labels and builds the config from them. With the stock caddy:latest image and a mounted Caddyfile, the labels are ignored and the Caddyfile is your only source of truth. Edit it there, reload Caddy, and stop waiting for the labels to do anything.
  2. Containers on separate networks. If the proxy and Matomo aren't on the same Docker network, the service name won't resolve at all, and you'll see a name-resolution error rather than connection refused. Put both on a shared network and the name starts resolving again.

First prove which failure you have

Before changing Matomo itself, read the proxy log and classify the failure. The log wording matters.

Proxy log saysWhat it meansFix
connect: connection refusedThe service name resolved, but the container port you dialed is closed.Use app:80 for the Apache image, or target the separate web container if you run FPM. Test both from inside the proxy container first.
host not found / no such hostThe proxy and Matomo aren't sharing Docker DNS.Put both services on a shared Compose network, then reload the proxy.
timeoutThe name may resolve, but traffic isn't reaching a healthy listener.Check container health, firewall rules, and whether the web server is actually running before touching Matomo config.
Redirect loop once the 502 is goneMatomo is getting HTTP from the proxy and doesn't know the original request was HTTPS.Add Matomo's reverse-proxy settings and forwarded headers (next section).

Then tell Matomo it's actually behind a proxy

Fixing the port gets traffic flowing again. Now every request reaches Matomo from the proxy's IP over plain HTTP, which breaks two things: visitor-IP logging and HTTPS URL generation. The official reverse-proxy FAQ covers the fix, and the settings live under [General] in config/config.ini.php:

ini
[General]
assume_secure_protocol = 1
force_ssl = 1
proxy_client_headers[] = HTTP_X_FORWARDED_FOR
proxy_host_headers[] = HTTP_X_FORWARDED_HOST
trusted_hosts[] = "analytics.example.com"
; only if Matomo is served from a sub-path:
; proxy_uri_header = 1

assume_secure_protocol tells Matomo the original request was HTTPS even though the proxy forwarded it as HTTP, which is what stops the redirect loops and mixed-content warnings. force_ssl makes Matomo build HTTPS links everywhere, including in scheduled-report emails. The proxy_client_headers[] and proxy_host_headers[] entries tell Matomo which forwarded headers carry the real client IP and host. trusted_hosts[] should contain the real public hostname; do not disable the trusted-host check just to clear the warning.

PHP uppercases header names, prefixes HTTP_, and turns dashes into underscores, which is why X-Forwarded-For becomes HTTP_X_FORWARDED_FOR in the config.

The proxy has to send the same headers Matomo is configured to trust. Caddy's reverse_proxy adds X-Forwarded-For, X-Forwarded-Proto, and X-Forwarded-Host by default. In Nginx you add them by hand:

nginx
proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host  $host;
proxy_set_header Host              $host;

The Matomo config names the PHP server variables it should trust. If your proxy does not send a forwarded host header, do not configure Matomo to depend on one.

If Matomo is served under a path such as /matomo, proxy_uri_header = 1 on its own is not enough. The proxy also has to send X-Forwarded-Uri. Matomo's FAQ uses proxy_set_header X-Forwarded-Uri /matomo; for Nginx; in Caddy, set the same upstream header in the route that handles the sub-path, and keep any prefix stripping or rewrite rules explicit.

With a CDN or load balancer in front of Caddy, there's one more step: configure Caddy trusted_proxies so it trusts the upstream X-Forwarded-* values, then check Matomo's visit log. On Matomo 5, if visits still show local or proxy IPs after you've enabled proxy_client_headers[], try proxy_ip_read_last_in_list = 0, which Matomo's FAQ suggests for multi-proxy chains.

Don't disable the trusted-host check just to silence Matomo's warning. Setting enable_trusted_host_check = 0 clears the warning and opens you up to host-header attacks in the same move. Add your real domain to trusted_hosts[] and leave the check on. Same quiet dashboard, none of the exposure. We go deeper on that in hardening a Matomo install.

Verify the whole thing works

Start with the public URL. The exact protocol version and status can vary, especially if Matomo redirects to a canonical host or path. The important part is that the proxy is no longer returning 502.

Run fromAny machine that can reach the public Matomo domain
curl -sSIk https://your-matomo-domain | head -1
Expect: A 2xx or 3xx status line, not 502.
If it fails: Read the proxy log first. The log wording tells you whether this is still a port, DNS, timeout, or redirect-loop problem.

Then check the application-level symptoms:

CheckExpected resultIf it fails
Visit logReal client IPs, not only the proxy container IPRe-check forwarded headers, CDN trusted-proxy settings, and Matomo's proxy IP order
Browser URLHTTPS pages with no redirect loop or mixed-content warningRe-check assume_secure_protocol, force_ssl, and forwarded proto
Trusted hostNo Matomo trusted-host warning for the public domainAdd the domain to trusted_hosts[]; do not disable the check
Archivercore:archive finishes without memory errorsIncrease CLI memory and move archiving to cron before resizing the VPS

After the 502 is fixed: prevent the next slowdown

The other question that comes up constantly is what VPS specs for X sites? The framing is usually off. Site count barely moves the needle. What actually drives the load is total traffic, how many segments you run, retention, plugins, database size, and archive concurrency.

Treat VPS numbers as field heuristics, not guarantees. For a handful of low-traffic sites with few segments and normal retention, a practical starting point is 2 vCPU, 4 GB RAM, and an SSD on a host that can resize without a rebuild. A 1-core, 2 GB box is a floor for experiments and very small installs, not a target for production planning.

Rather than guess at specs, pull the levers that actually move the load:

  1. Move archiving to cron. This is the single biggest lever. By default Matomo processes report archives on browser requests, so one dashboard view can kick off a heavy archiving run and spike CPU and RAM with no warning. Put the core:archive console command on a cron (hourly is typical), then under Administration → System → General settings → Archiving set Archive reports when viewed from the browser to No. To stop browser-triggered archiving even for custom segments, add browser_archiving_disabled_enforce = 1 under [General]. We've written up the full cron-archiving setup separately, since it's the change that clears most "Matomo is slow" and "Matomo ate my server" reports.
  2. Give PHP-CLI room to work. Archiving is the memory-hungry part of Matomo, and a stingy memory_limit produces failed or partial archives that look an awful lot like data loss. Run the archiver under a CLI config with a generous limit instead of the default web one. There's more on scaling archiving CPU and memory once the database grows.
  3. Tune MariaDB or MySQL. Matomo is database-bound. As the data grows, the InnoDB buffer pool and max_allowed_packet matter far more than any web-server tuning, and an undersized database is where most Matomo database errors come from anyway.
  4. Plan disk for growth and backups. Raw data piles up, and a mysqldump needs at least as much free space as the database itself. Run out mid-backup and you've traded one problem for a worse one.

Scale CPU first when archiving runs start lagging, then RAM, then disk. Keep an eye on your core:archive run-times. They're the early warning that you've outgrown the box, well before the dashboard feels slow.

What we'd actually do

If you're staring at a 502 right now, don't touch config.ini.php yet. Read the proxy log first. connection refused means a port mismatch, almost always 8080 in the upstream where it should be 80 for the Apache image. Fix that one line and reload the proxy. A different error, like a timeout or a name-resolution failure, is a different problem, usually the network setup or the FPM image, and none of the Matomo config settings help until traffic actually flows.

Once the 502 is gone, do the proxy-awareness settings in one pass: assume_secure_protocol, force_ssl, the forwarded-header lines your proxy actually sends, and your domain in trusted_hosts[]. Then move archiving to cron before you think about sizing at all. That one change settles more "I need a bigger server" complaints than a bigger server ever does.

Martez 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 if that's relevant.

A 502 behind a proxy almost never means Matomo is broken. It means the proxy is knocking on the wrong door.