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.
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:
app:
image: matomo
ports:
- 8080:808080: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.
# Caddy
reverse_proxy app:8080
# Nginx
proxy_pass http://app:8080;# 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
Browser
- HTTPS2
Host :443
host :8080Maps to container :80, on the VM's interface only - 3
Caddy or Nginx container
app:8080No listener: connection refused - app:804
Matomo Apache container :80
The FPM image is a different animal
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
- Caddy labels versus a mounted Caddyfile. Plenty of compose files float around with
caddy.reverse_proxylabels on the Matomo service. Those labels only do something with thelucaslorentz/caddy-docker-proxyimage, which reads container labels and builds the config from them. With the stockcaddy:latestimage and a mountedCaddyfile, 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. - 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 covers the fix, and the settings live under [General] in config/config.ini.php:
[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 = 1assume_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:
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.
curl -sSIk https://your-matomo-domain | head -1Then 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:
- 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:archiveconsole 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, addbrowser_archiving_disabled_enforce = 1under[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. - Give PHP-CLI room to work. Archiving is the memory-hungry part of Matomo, and a stingy
memory_limitproduces 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. - Tune MariaDB or MySQL. Matomo is database-bound. As the data grows, the InnoDB buffer pool and
max_allowed_packetmatter far more than any web-server tuning, and an undersized database is where most Matomo database errors come from anyway. - Plan disk for growth and backups. Raw data piles up, and a
mysqldumpneeds 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.