
Run a Laravel app and a Next.js app on the same VPS with Docker, and let Traefik route each domain and issue Let's Encrypt certificates automatically — just labels, no nginx configs.
For a long time my VPS setup looked like this: one nginx on the host, one config file per site, and a certbot command I had to remember every time I added a domain. It works. It also means every new project starts with fifteen minutes of copying an old nginx file and changing three lines — and getting one of them wrong.
These days I run every project on the box as its own Docker Compose stack, and a single Traefik container sits in front of all of them. Adding a new app with its own domain and HTTPS is now a handful of labels in that app's docker-compose.yml. No nginx file, no certbot, no reload.
In this article I'll set up Traefik once, then put two apps behind it — a Laravel app and a Next.js app — each on its own domain with its own Let's Encrypt certificate.
┌──────────── VPS ────────────┐
:80 / :443 │ │
───────────▶ │ traefik (ports 80, 443) │
│ │ reads Docker labels │
│ ├──▶ laravel :8000 │ api.example.com
│ └──▶ nextjs :3000 │ app.example.com
│ │
│ shared network: "traefik" │
└─────────────────────────────┘traefik, so Traefik can reach it.app.example.com to me on port 3000, with a certificate."Point the DNS before you start the apps. Create an
Arecord for each domain (api.example.com,app.example.com) pointing at your VPS IP. Let's Encrypt validates by calling your domain over HTTP — if DNS still points somewhere else, validation fails, and failed validations are rate-limited.
Traefik and every app talk over one network. Create it once, outside any Compose file, so no single stack owns it:
docker network create traefikThat's why the Compose files below mark it external: true — they join an existing network instead of creating their own.
I keep Traefik in its own folder, completely separate from the apps:
mkdir traefik && cd traefik
nano docker-compose.ymlservices:
traefik:
image: traefik:v3.6
container_name: traefik
restart: unless-stopped
command:
- "--api.dashboard=false"
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--entrypoints.web.address=:80"
- "--entrypoints.websecure.address=:443"
- "--certificatesresolvers.letsencrypt.acme.email=you@example.com"
- "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json"
- "--certificatesresolvers.letsencrypt.acme.httpchallenge=true"
- "--certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web"
ports:
- "80:80"
- "443:443"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./letsencrypt:/letsencrypt
networks:
- traefik
networks:
traefik:
external: trueWhat each part is doing:
providers.docker=true — Traefik reads its routing rules from container labels.exposedbydefault=false — this one matters. Without it, every container on the box gets a route, including your database. With it, a container is only exposed when it explicitly says traefik.enable=true.entrypoints — web listens on 80, websecure on 443. Apps pick which one they serve on.certificatesresolvers.letsencrypt — a resolver named letsencrypt. Apps reference it by that name to get a certificate. The HTTP challenge answers Let's Encrypt on port 80, which is why web must stay reachable.acme.json — every certificate is stored here, in ./letsencrypt on the host. It survives container restarts, so Traefik doesn't request new certificates every time it boots.api.dashboard=false — the dashboard is handy, but an exposed dashboard is one more thing to secure. I leave it off.Use your real email for acme.email — Let's Encrypt sends expiry warnings there if renewal ever fails.
Start it:
docker compose up -d
docker compose logs -f traefikNothing is routed yet, and that's expected. Traefik is waiting for containers with labels.
As written, http://app.example.com won't redirect to HTTPS. I add two lines to Traefik's command so every app gets the redirect for free:
- "--entrypoints.web.http.redirections.entrypoint.to=websecure"
- "--entrypoints.web.http.redirections.entrypoint.scheme=https"The HTTP challenge keeps working with this on — Traefik answers Let's Encrypt's challenge requests before the redirect applies.
Any Next.js Docker image works here. If you don't have one yet, the Dockerfile from my Next.js Docker rollout post is the one I use. The Compose file:
services:
app:
image: next-app:latest
restart: unless-stopped
env_file: .env
environment:
HOSTNAME: 0.0.0.0
PORT: 3000
labels:
- "traefik.enable=true"
- "traefik.http.routers.nextjs.rule=Host(`app.example.com`)"
- "traefik.http.routers.nextjs.entrypoints=websecure"
- "traefik.http.routers.nextjs.tls.certresolver=letsencrypt"
- "traefik.http.services.nextjs.loadbalancer.server.port=3000"
networks:
- traefik
networks:
traefik:
external: trueLine by line, the labels say:
traefik.enable=true — expose this container (we turned auto-exposure off).routers.nextjs.rule — requests for app.example.com go to this router.entrypoints=websecure — serve it on 443.tls.certresolver=letsencrypt — get a certificate from the resolver we defined in Traefik.loadbalancer.server.port=3000 — the port the app listens on inside the container.Notice what's missing: no ports: section. The app never touches the host's ports. Traefik reaches it over the traefik network, so you can't accidentally serve the app on http://your-ip:3000.
docker compose up -dWatch Traefik's logs — within a few seconds it requests the certificate. Open https://app.example.com and you get the app with a valid certificate.
Laravel is slightly more interesting because it has a database, and the database must not be on the shared network. I give the stack a second, private network:
services:
app:
image: laravel-app:latest
restart: unless-stopped
env_file: .env
labels:
- "traefik.enable=true"
- "traefik.http.routers.laravel.rule=Host(`api.example.com`)"
- "traefik.http.routers.laravel.entrypoints=websecure"
- "traefik.http.routers.laravel.tls.certresolver=letsencrypt"
- "traefik.http.services.laravel.loadbalancer.server.port=8000"
# The app is on two networks — tell Traefik which one to use.
- "traefik.docker.network=traefik"
networks:
- traefik
- internal
depends_on:
- mysql
mysql:
image: mysql:8.4
restart: unless-stopped
environment:
MYSQL_DATABASE: ${DB_DATABASE}
MYSQL_USER: ${DB_USERNAME}
MYSQL_PASSWORD: ${DB_PASSWORD}
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
volumes:
- mysql-data:/var/lib/mysql
networks:
- internal
networks:
traefik:
external: true
internal:
volumes:
mysql-data:Two things are different from the Next.js stack:
internal. The Laravel app can reach it as mysql (set DB_HOST=mysql in .env), but Traefik, the Next.js app, and the internet can't. It has no labels and no ports, so there's no route to it at all.traefik.docker.network=traefik — when a container is on more than one network, Traefik has to pick one IP to send traffic to. If it picks the internal IP, it can't reach it, and you get a 502 Bad Gateway that looks completely random. This label removes the guessing.Port 8000 assumes Octane with FrankenPHP, which is what I run — the full image is in my Laravel Docker deploy post. Use whatever port your image listens on.
SSL ends at Traefik. Laravel receives plain HTTP, so out of the box it generates http:// URLs — mixed-content warnings, broken asset links, and redirects to the wrong scheme. Traefik sends X-Forwarded-Proto: https; Laravel just needs to trust it. In Laravel 11 and up:
->withMiddleware(function (Middleware $middleware) {
// Traefik terminates TLS. Trust its X-Forwarded-* headers.
$middleware->trustProxies(at: '*');
})Trusting * is safe here only because the container isn't reachable except through Traefik — no published ports, remember.
docker compose up -dhttps://api.example.com is live with its own certificate, and the Next.js app is untouched.
This is the part that sold me. A third project is:
A record for the new domain.traefik network and add the five labels with a new router name and the new domain.docker compose up -d.No nginx file, no certbot, no restarting anything that's already running. Renewals happen automatically too — Traefik renews certificates well before they expire.
traefik.http.routers.app... in two different stacks means two containers fighting over one router, and one of your sites quietly serves the other's content. Name routers after the project (nextjs, laravel, shop), never app.traefik.docker.network = random 502s. If a container is on more than one network, always add the label.exposedbydefault=false. Leaving it out gives every container a route, including databases and queue workers.--certificatesresolvers.letsencrypt.acme.caserver=https://acme-staging-v02.api.letsencrypt.org/directory while testing, then remove it and delete letsencrypt/acme.json so Traefik fetches real certificates.acme.json yourself, it must be chmod 600. Traefik refuses to use a certificate store other users can read. Letting Traefik create the file avoids this.:ro limits what Traefik can do through it, but anything with access to the socket effectively controls Docker. Keep the Traefik image official and up to date, and keep the dashboard off.ports: to the apps. Traefik is the front door. A published app port is a second door with no TLS — and Docker opens it past ufw, so your firewall won't stop it.http:// links? That's the missing trustProxies call.The idea is simple: one container owns ports 80 and 443, and every app describes its own domain and certificate with labels. Traefik does the routing and the Let's Encrypt work, and each project stays a self-contained Compose stack you can start, stop, or delete without touching the others.
If you also want deploys without downtime, Traefik works nicely with the docker-rollout setup: it discovers new containers by their labels, so you don't need the nginx DNS re-resolution trick from that post. And once a few apps share one box, the server maintenance checklist keeps disk and Docker images under control.