This blog runs on the same small VPS as another, bigger WordPress project. I didn’t want to pay for a second server, and I didn’t want the two projects to know about each other either: separate repositories, separate databases, separate deploys. A push to one must never restart the other.
The setup is simple — one Traefik, many Compose stacks — but getting there produced three bugs worth writing down, because each of them looked like something else.
The shape of it
- One Traefik owns ports 80 and 443 and the Let’s Encrypt resolver. It lives in the first project’s stack and watches Docker for labels.
- A shared external network that Traefik is attached to. Every site’s web container joins it; nothing else does.
- Each project is its own repository and its own
docker composeproject in its own directory, with its own database and Redis on a private default network. It declares its routes with labels. - Each repository deploys itself: a GitHub Actions job SSHes in, pulls, and runs
docker compose up -d --buildin its directory. Compose only touches services in its own project, so the neighbour is never restarted.
services:
wordpress:
build: .
networks: [default, traefik]
labels:
- traefik.enable=true
- traefik.docker.network=${TRAEFIK_NETWORK}
- traefik.http.routers.blog.rule=Host(`${DOMAIN}`)
- traefik.http.routers.blog.entrypoints=websecure
- traefik.http.routers.blog.tls.certresolver=letsencrypt
- traefik.http.routers.blog-http.rule=Host(`${DOMAIN}`)
- traefik.http.routers.blog-http.entrypoints=web
- traefik.http.routers.blog-http.middlewares=blog-https
- traefik.http.middlewares.blog-https.redirectscheme.scheme=https
- traefik.http.services.blog.loadbalancer.server.port=8080
db:
image: mariadb:10.11
# default network only — never on the shared one
networks:
traefik:
name: ${TRAEFIK_NETWORK}
external: true
Two labels people forget: traefik.docker.network, because a container on two networks otherwise gets routed via whichever IP Traefik picks first (often the private one it can’t reach), and the explicit HTTP→HTTPS router, because the shared Traefik may not have a global redirect and you shouldn’t add one to someone else’s config.
Bug 1: “Access denied for user” — to the wrong database
First deploy: the new site couldn’t connect to its database. The credentials were right, the database container was healthy, and MariaDB’s error said access was denied for my user… from an IP address that wasn’t in my stack.
Docker’s embedded DNS resolves service names on every network a container is attached to. My web container was on its private network and the shared one. The neighbouring project’s web container was also on the shared one — and its stack had a service called db too, attached there for reasons of its own. DB_HOST=db was a coin toss between two databases, and the coin kept landing on the wrong one.
The fix is to stop using generic names for anything a container on a shared network resolves. Give the private services project-specific aliases and use those:
wordpress:
environment:
- DB_HOST=blog-db
- WP_REDIS_HOST=blog-redis
db:
networks:
default:
aliases: [blog-db]
redis:
networks:
default:
aliases: [blog-redis]
Redis is the scarier half of this bug, by the way. The database at least refused the connection. A shared Redis would have accepted it, and two sites would have been quietly reading each other’s object cache.
Bug 2: the site that Traefik refused to route
With the database fixed, the site worked from inside the container and returned 404 from outside. Traefik’s dashboard didn’t list the router at all.
Traefik’s Docker provider skips containers whose healthcheck reports unhealthy (or still starting). The base image’s healthcheck curled /fpm-ping, and my custom nginx config — written for WordPress, replacing the image’s default — no longer had that location. The container was perfectly fine and permanently “unhealthy”. Adding the location back, restricted to localhost, fixed routing:
location ~ ^/(fpm-status|fpm-ping)$ {
access_log off;
allow 127.0.0.1;
deny all;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_pass unix:/run/php-fpm.sock;
}
Lesson: when you replace a base image’s config, read its HEALTHCHECK first. docker inspect --format '{{json .State.Health}}' would have told me in ten seconds.
Bug 3: ERR_TOO_MANY_REDIRECTS on wp-admin
The front end worked over HTTPS. /wp-admin redirected to itself until the browser gave up.
TLS terminates at Traefik, which talks plain HTTP to the container. PHP therefore sees HTTPS unset, is_ssl() returns false, and with FORCE_SSL_ADMIN on, WordPress redirects to the HTTPS URL — which arrives at the container as HTTP again. Forever.
The usual fix is a snippet in wp-config.php that trusts X-Forwarded-Proto. I prefer doing it in nginx, so PHP gets a correct $_SERVER['HTTPS'] and every library — not just WordPress — sees the truth:
map $http_x_forwarded_proto $fastcgi_https {
default $https;
https on;
}
# in every PHP location, after `include fastcgi_params;`
fastcgi_param HTTPS $fastcgi_https if_not_empty;
This is only safe because the container is reachable solely through Traefik. If the web port were published on the host, anyone could send X-Forwarded-Proto: https over plain HTTP. Don’t publish it.
Checklist for the next site on the box
- Unique router, middleware and service names in labels — they are global across all stacks in Traefik.
- Project-specific hostnames for the database and cache; never
dborredis. - Only the web container on the shared network, with
traefik.docker.networkset. - A healthcheck that actually passes with your config.
- HTTPS forwarded to PHP, and no published web port.
- A deploy that only ever runs
docker composeinside its own project directory.