Several environments on one server
What this page solves
Three non-production environments of the same project — dev, test, staging — on the same
server, each with its own containers, database and volumes, and a Traefik in front routing
by hostname with TLS. Everything here is optional: whoever keeps bringing up one stack with
docker compose --profile app up -d --build is not affected by anything on this page, and the
kit’s docker-compose.yml does not change a single line.
The central mechanism: one project name per environment
Compose isolates by project name. The kit was already designed for that: no service declares
a container_name, and the prefix of every container, network and volume comes from
COMPOSE_PROJECT_NAME in the .env — kit:install writes your project’s name there (the name
of the containers). Give each
environment a distinct name and you get, with no further configuration:
- containers with unique names —
projeto-dev-app-1,projeto-homol-pgsql-1; - one private network per environment — the nginx
app:9000resolves inside the right network; - prefixed volumes —
projeto-dev_pgsql-dataandprojeto-homol_app-storagenever mix.
$ docker compose ps # in /srv/projeto-devprojeto-dev-app-1 projeto-dev-nginx-1 projeto-dev-pgsql-1 projeto-dev-redis-1 …$ docker compose ps # in /srv/projeto-homolprojeto-homol-app-1 projeto-homol-nginx-1 …What is left to guarantee is one .env per environment (with its own APP_KEY, APP_URL
and COMPOSE_PROJECT_NAME) and ports that do not collide on the host — or no ports at all,
which is what Traefik allows.
Traefik in front
The scenario confirmed with the server’s DevOps: a Traefik is already running, with the docker
provider (it reads container labels), routing by Host(...) and terminating TLS on the
websecure entrypoint. Each environment must be on the same Docker network as Traefik — in
the example, my-network. The backend is the nginx internal port 80; the kit’s nginx.conf
does not change.
Only the nginx of each stack joins that network. The rest (app, pgsql, redis, queue,
scheduler) stays on the environment’s private network, invisible from outside. Two details the
kit’s example already carries and that are usually forgotten:
traefik.docker.networkis mandatory: nginx sits on two networks (the private one and Traefik’s), and without that label Traefik may pick the IP on the wrong network — the symptom is a 504.- Router and service names are global in Traefik: they must be unique per environment. The
example uses the
COMPOSE_PROJECT_NAMEitself, so uniqueness comes for free with the project name. - Service names are global on the shared network too. Docker’s DNS resolves a name across
every network of the container that asks: the kit’s
nginx, onmy-network, resolvesappthere as well, and if another project on the server has a container namedappon that network, thefastcgi_pass app:9000may land on the wrong project; with the Reverb block enabled,reverbjoins the network and resolvespgsqlandredisthe same way. Confirm with whoever runs Traefik (DevOps) thatmy-networkhas no other service with those names — and only the kit’snginx(andreverb, if enabled) joins it, neverapp.
This is what the example override declares (with projeto-dev and dev.example.org coming from the .env):
nginx: networks: - default # keeps talking to app:9000 - traefik # Traefik's network (real name in TRAEFIK_REDE) labels: - traefik.enable=true - traefik.docker.network=my-network - traefik.http.routers.projeto-dev.rule=Host(`dev.example.org`) - traefik.http.routers.projeto-dev.entrypoints=websecure - traefik.http.routers.projeto-dev.tls=true - traefik.http.services.projeto-dev.loadbalancer.server.port=80networks: traefik: external: true name: my-networkEnabling it on a server, step by step
The kit ships an example override at docker/traefik/docker-compose.override.yml. Compose loads a
docker-compose.override.yml sitting at the checkout root on its own, with no -f — so the
usual command, and ./deploy_docker_local.sh, start applying the wiring without any change.
-
Copy the example to the root of that environment’s checkout:
Janela do terminal cp docker/traefik/docker-compose.override.yml docker-compose.override.ymlThe copy is in the kit’s
.gitignore, like.env: it is server configuration. (Project born from an earlier kit version: add the line/docker-compose.override.ymlto your.gitignore—kit:updatedoes not deliver that file.) -
Fill in that environment’s
.env— the block is ready at the end of.env.docker:COMPOSE_PROJECT_NAME=projeto-dev # one per environmentAPP_URL=https://dev.example.org # the public hostnameTRUSTED_PROXIES=* # Laravel starts honouring Traefik's X-Forwarded-ProtoTRAEFIK_HOST=dev.example.org # mandatory: Compose refuses to start without it# TRAEFIK_REDE=my-network # only if Traefik's network has another nameTRAEFIK_HOSThas no default on purpose: without itdocker composestops with required variable TRAEFIK_HOST is missing instead of bringing up a router that routes nothing. -
Bring it up and check:
Janela do terminal docker compose --profile app up -d --builddocker compose --profile app config | grep -A8 'labels:' # the labels, with your project's nameTo turn it off, delete
docker-compose.override.ymland runupagain: nginx leaves Traefik’s network and everything returns to what it was.
Why TRUSTED_PROXIES
Traefik terminates TLS and delivers HTTP on the container’s port 80. Without trusting the
X-Forwarded-* headers it sends, Laravel believes it is on http://: it generates wrong absolute
links, does not mark the session cookie as secure and redirects to addresses the browser cannot
reach. TRUSTED_PROXIES says whom to believe — a comma-separated list of IPs/CIDRs, or *.
Empty or absent, nothing changes: that is the behaviour the kit always had.
* trusts whoever reaches the nginx port 80: with FORWARD_APP_PORT on loopback (next section)
that excludes the internet and the host, but not the other containers on my-network — the network
is shared, and a neighbouring container that reaches nginx:80 directly forges X-Forwarded-For and
X-Forwarded-Proto (the IP the login rate limit and the authentication trail record). What closes
this is the fixed IP of Traefik (ipv4_address on the network, ask the DevOps) in
TRUSTED_PROXIES instead of *; the CIDR of the whole network does not help, because the neighbours
are inside it. If * stays, it stays as an accepted risk among projects on the same server.
The key is read like any other .env key, through config/kit.php, and applied at the kit’s boot.
Items that are neither IP nor CIDR, and wildcards inside a list (**, REMOTE_ADDR,
PRIVATE_SUBNETS), are discarded with a warning in the log — a typo in TRUSTED_PROXIES never takes
the application down nor widens trust. With the configuration cached (config:cache) the value
TRUSTED_PROXIES had in the .env at cache time is what counts: rebuild the cache when you change it.
Ports: distinct per environment, and on loopback unless they must go out
Behind Traefik the browser traffic needs no port on the host at all. But the kit’s
docker-compose.yml always publishes the ports of pgsql, redis, nginx and reverb, and
Compose adds the override’s ports to the base file’s — the override cannot remove a publication.
Across three environments on the same server that collides: the second up stops with port is
already allocated. So the four FORWARD_* keys are mandatory and distinct per environment, and
what is “optional” is exposing them outward — loopback in the value itself keeps them for
administration only:
FORWARD_APP_PORT=127.0.0.1:8090 # publishes on loopback only: handy to debug on the machine, invisible from outsideIt is also what makes TRUSTED_PROXIES=* safe: with the nginx port on 127.0.0.1, only Traefik
reaches the container. A matrix that works:
| Variable | dev | test | staging |
|---|---|---|---|
FORWARD_APP_PORT |
127.0.0.1:8090 | 127.0.0.1:9090 | 127.0.0.1:8080 |
FORWARD_DB_PORT |
127.0.0.1:5433 | 127.0.0.1:5434 | 127.0.0.1:5435 |
FORWARD_REDIS_PORT |
127.0.0.1:6380 | 127.0.0.1:6381 | 127.0.0.1:6382 |
FORWARD_REVERB_PORT |
8190¹ | 8191¹ | 8192¹ |
¹ With Reverb through Traefik (next section) it can also go to 127.0.0.1:; outside it, it is the
port the browser uses and stays open. Either way the default FORWARD_REVERB_PORT=8090 collides with
the port 8090 the matrix reserved for dev’s app — hence the offsets.
The original survey marked the first three as “optional”; in the kit they are not, because the base
file always publishes them. Leaving one undefined is the mistake that keeps the second environment
from starting. The same goes for the profiles you enable: ai publishes FORWARD_LLAMA_PORT
(default 8080 — it collides with staging’s app in the matrix) and FORWARD_EMBED_PORT (8081), mail
publishes FORWARD_MAILPIT_PORT (1025) and FORWARD_MAILPIT_DASHBOARD_PORT (8025); on the
environment that hosts them, move them to loopback with their own values
(FORWARD_LLAMA_PORT=127.0.0.1:8180).
Reverb: two routes
The WebSocket can take two paths, and the choice belongs to the server, not to the kit:
-
Through Traefik, on the same hostname (recommended): uncomment the block between
# >>> reverb-traefikand# <<< reverb-traefikin the override. It creates a second router,{project}-reverb, matchingHost(...)withPathPrefix(/app/{REVERB_APP_KEY})andPathPrefix(/apps/{REVERB_APP_ID})— the WebSocket and the API Reverb serves — and pointing at the container’s port 8090. Narrowing by key and id is not decoration:PathPrefix(/app)alone would steal the kit’s/apppanel, because in Traefik the longest rule wins. No extra port exposed, TLS for free. In the.envonly theVITE_REVERB_*values change — what the browser uses, if your front end has Echo;REVERB_HOST,REVERB_PORTandREVERB_SCHEMEdo not change: they are internal (PHP to the WebSocket container), and the compose file already pointsappat port 8090 of that container over plain HTTP (aREVERB_SCHEME=httpswould make broadcasting fail on a TLS the WebSocket container does not serve). The browser values vary per environment and are baked at build time — change them and runupwith--build:VITE_REVERB_HOST=dev.example.orgVITE_REVERB_PORT=443VITE_REVERB_SCHEME=httpsWhat the block enables, already interpolated:
reverb:networks: [default, traefik]labels:- traefik.enable=true- traefik.docker.network=my-network- traefik.http.routers.projeto-dev-reverb.rule=Host(`dev.example.org`) && (PathPrefix(`/app/starter-kit-key`) || PathPrefix(`/apps/starter-kit`))- traefik.http.routers.projeto-dev-reverb.entrypoints=websecure- traefik.http.routers.projeto-dev-reverb.tls=trueplus the service of the same name pointing at the container’s port 8090 (the
loadbalancerline of the example). -
Its own port on the host: uncomment nothing and use
FORWARD_REVERB_PORTwith the matrix offset — it is the port the browser connects to, so it stays open outward:FORWARD_REVERB_PORT=8190
One checkout per environment (Option A)
This is the recommended layout: each environment is a folder, on a branch, with its own .env
and its own copy of the override.
/srv/projeto-dev → branch develop, .env with COMPOSE_PROJECT_NAME=projeto-dev/srv/projeto-teste → test branch, .env with COMPOSE_PROJECT_NAME=projeto-teste/srv/projeto-homol → release branch, .env with COMPOSE_PROJECT_NAME=projeto-homolThe three .env files, in what differs between them (the rest — an APP_KEY generated in each,
TRUSTED_PROXIES=*, database, cache — has the same shape):
COMPOSE_PROJECT_NAME=projeto-devTRAEFIK_HOST=dev.example.orgAPP_URL=https://dev.example.orgFORWARD_APP_PORT=127.0.0.1:8090FORWARD_DB_PORT=127.0.0.1:5433FORWARD_REDIS_PORT=127.0.0.1:6380FORWARD_REVERB_PORT=8190COMPOSE_PROJECT_NAME=projeto-testeTRAEFIK_HOST=test.example.orgAPP_URL=https://test.example.orgAPP_DEBUG=falseFORWARD_APP_PORT=127.0.0.1:9090FORWARD_DB_PORT=127.0.0.1:5434FORWARD_REDIS_PORT=127.0.0.1:6381FORWARD_REVERB_PORT=8191COMPOSE_PROJECT_NAME=projeto-homolTRAEFIK_HOST=staging.example.orgAPP_URL=https://staging.example.orgAPP_DEBUG=falseFORWARD_APP_PORT=127.0.0.1:8080FORWARD_DB_PORT=127.0.0.1:5435FORWARD_REDIS_PORT=127.0.0.1:6382FORWARD_REVERB_PORT=8192Updating an environment is the usual flow, in its folder: git pull then rebuild, in that order —
the image is self-contained, and rebuilding before the pull bakes the old code again.
cd /srv/projeto-devgit pulldocker compose --profile app up -d --builddocker compose ps # only this environment's containersdocker compose logs -f appOr all at once with ./deploy_docker_local.sh, which does the pull, the rebuild, the up, the
migrations and the health check — and reads the port published by Docker itself, so it works with
the 127.0.0.1 bind.
Pros: independent branches, .env per folder, zero changes to the compose file, restart: unless-stopped survives a reboot. Cons: three images on disk and three Postgres/Redis pairs in
memory — acceptable for database and cache, which are cheap; for what is expensive, see Option D.
The other options (B, C and D), and why not
Option B — a single checkout with -p and --env-file
docker compose -p projeto-dev --env-file .env.dev up. Two conflicts with the kit’s compose file:
env_file: .env and the bind ./.env:/var/www/.env are fixed, and --env-file only changes
interpolation, not those two. Besides, one checkout is one branch: the three environments would run
the same code. Discarded.
Option C — one override file per environment
docker-compose.dev.yml… combined with -f. Since everything is already an .env variable, you
gain one more file per environment and nothing over the layout above. Discarded.
Option D — shared infrastructure
One environment hosts what is expensive and the others point at it. Worth it for llama.cpp
(~8 GB of RAM per instance) and for Mailpit, which stay shared; not for pgsql and redis
(Postgres and Redis), which are cheap, stay per environment, and whose full isolation is what makes a
migrate:fresh in dev harmless for staging. The
recipe needs a network of its own between the environments (not Traefik’s: only nginx joins
that one), shared by the consumer’s app and queue and the host’s llamacpp — all in each side’s
override, overriding the keys the base compose file fixes:
# override of the CONSUMER (test, staging): docker network create ia-compartilhada, onceservices: app: networks: [default, ia] environment: LLAMACPP_URL: 'http://projeto-dev-llamacpp-1:8080/v1' LLAMACPP_EMBED_URL: 'http://projeto-dev-llamacpp-embeddings-1:8080/v1' queue: networks: [default, ia] environment: LLAMACPP_URL: 'http://projeto-dev-llamacpp-1:8080/v1' LLAMACPP_EMBED_URL: 'http://projeto-dev-llamacpp-embeddings-1:8080/v1'networks: ia: external: true name: ia-compartilhada# override of the HOST (dev): comes up with --profile ai, and publishes llama on loopback only# (FORWARD_LLAMA_PORT=127.0.0.1:8180: the default 8080 collides with staging's app in the matrix)services: llamacpp: networks: [default, ia] llamacpp-embeddings: networks: [default, ia]networks: ia: external: true name: ia-compartilhadaThe same global-name warning applies: only those containers join ia-compartilhada, by their
full names (projeto-dev-llamacpp-1), never app.
Pitfalls
- Cookies: solved by the hostname — each environment has its own, and cookies are per host. No
custom
SESSION_COOKIE. - A different
APP_KEYper environment: encrypted sessions and cookies become incompatible with each other, which reinforces the isolation.php artisan key:generatein each.env. APP_URLper environment, withhttps://;APP_DEBUG=falseoutside dev.VITE_*is baked at build time:npm run buildruns inside the image, without the.env. The kit’s Dockerfile acceptsVITE_REVERB_HOST,VITE_REVERB_PORT,VITE_REVERB_SCHEMEandVITE_REVERB_APP_KEYas build arguments, and the example override passes them from the.env. This matters for a project with Echo on the front end; the kit’s JavaScript does not consume those variables by default — empty arguments produce the same image as today.- Changing
COMPOSE_PROJECT_NAMEafter the firstupcreates new volumes; the old data stays in the volume under the previous name.
What does not change
The docker-compose.yml, the Dockerfile.laravel (apart from the build arguments, empty by
default), the nginx.conf, the deploy_docker_local.sh and the commands documented in
Advanced installation. Without the override
at the root and without the keys in the .env, docker compose config is identical to what it
was before this page.