# 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.

Published: 2026-06-29
Categories: Matomo Self-Hosting
Canonical: https://martez.io/blog/self-host-matomo-reverse-proxy-502

---

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:

```
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.

<Callout type="tip" title="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.

</Callout>

## 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](https://docs.docker.com/compose/how-tos/networking/) 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.

<ConfigDiff
  title="Reverse proxy upstream"
  before={`# Caddy
reverse_proxy app:8080

# Nginx
proxy_pass http://app:8080;`}
  after={`# 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.

<FlowSteps
  caption="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."
  steps={[
    { title: "Browser" },
    {
      title: "Host :443",
      arrow: "HTTPS",
      branch: {
        label: "host :8080",
        title: "Maps to container :80, on the VM's interface only",
        tone: "muted",
      },
    },
    {
      title: "Caddy or Nginx container",
      branch: { label: "app:8080", title: "No listener: connection refused" },
    },
    { title: "Matomo Apache container :80", arrow: "app:80", tone: "ok" },
  ]}
/>

### The FPM image is a different animal

<Callout type="info" title="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](https://github.com/matomo-org/docker/tree/master/.examples/nginx): 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.
</Callout>

### 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`](https://github.com/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 says | What it means | Fix |
| --- | --- | --- |
| `connect: connection refused` | The 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 host` | The proxy and Matomo aren't sharing Docker DNS. | Put both services on a shared Compose network, then reload the proxy. |
| `timeout` | The 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 gone | Matomo 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](https://matomo.org/faq/how-to-install/faq_98/) 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`](https://caddyserver.com/docs/caddyfile/directives/reverse_proxy#headers) 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](/blog/harden-secure-matomo-installation).

## 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.

<CommandCheck
  command="curl -sSIk https://your-matomo-domain | head -1"
  runFrom="Any machine that can reach the public Matomo domain"
  expect="A 2xx or 3xx status line, not 502."
  onFail="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:

| Check | Expected result | If it fails |
| --- | --- | --- |
| Visit log | Real client IPs, not only the proxy container IP | Re-check forwarded headers, CDN trusted-proxy settings, and Matomo's proxy IP order |
| Browser URL | HTTPS pages with no redirect loop or mixed-content warning | Re-check `assume_secure_protocol`, `force_ssl`, and forwarded proto |
| Trusted host | No Matomo trusted-host warning for the public domain | Add the domain to `trusted_hosts[]`; do not disable the check |
| Archiver | `core:archive` finishes without memory errors | Increase 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](/blog/set-up-matomo-cron-archiving) 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](/blog/matomo-archiving-memory-cpu-scaling) 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](/blog/fix-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](/?utm_source=martez&utm_medium=blog&utm_campaign=self-host-matomo-reverse-proxy-502) 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=self-host-matomo-reverse-proxy-502) 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.
