Zeltro › Run multiple projects locally without port conflicts
Running a dozen projects locally without port conflicts
Every project wants port 3000, every compose file wants 3306, and nothing can talk to anything. Here is the arrangement that removes the problem instead of managing it — shared services, and an address assigned to every project so you never pick one.
The problem arrives about the fifth project. Every framework wants port 3000 or
8000. Every docker-compose.yaml you clone wants to bind 3306 or 5432. You start
keeping a note of which project is on which port, and then you need two projects
to talk to each other and discover host.docker.internal.
It is all manageable. It is also avoidable — not by memorising a better set of ports, but by never choosing one.
The arrangement#
Each project gets its own address on a private Docker network, assigned when the project is created. You never pick it, and two projects cannot collide:
LOCAL http://10.247.177.168 # from this machine
LAN http://192.168.1.6:168 # from anything else on the network
zeltro ps prints both for every project. The local address is the container's
own IP, so there is no port on it at all; the LAN address is this machine's IP
plus the project's published port, for reaching it from your phone or another
laptop.
Underneath, every project on the machine shares one set of services:
| Service | Container | Reachable from a project as |
|---|---|---|
| MariaDB | zeltro-mariadb |
zeltro-mariadb:3306 |
| Postgres | zeltro-postgres |
zeltro-postgres:5432 |
| Redis | zeltro-redis |
zeltro-redis:6379 |
| MongoDB | zeltro-mongo |
zeltro-mongo:27017 |
| Memcached | zeltro-memcached |
zeltro-memcached:11211 |
Ten projects, one of each. Seven duplicate Postgres containers eating roughly 700 MB becomes one eating about 100.
What this fixes#
Port roulette. Nothing to remember, nothing to allocate, no collisions when two projects both default to 3000. The allocation is Zeltro's problem, not yours.
Cross-project work. Every project is on the same Docker network and is
reachable there by name, so fetch('http://client-api/') from inside another
project works with no networking configuration. A Laravel app reading a Django
project's database is a connection string, not an exercise.
Cloned repos that fight you. An upstream compose file binding 5432:5432 or
80:80 gets rewired to the shared services on setup, with the original kept as
docker-compose.upstream.yaml.
Machine load. One database process per engine instead of one per project, which is the difference between a laptop that can hold your whole workload and one that cannot.
Doing it#
zeltro new laravel barber-shop # scaffold, wire up, start
zeltro setup existing-project # adapt a project you already have
zeltro up barber-shop # start it
zeltro ps # what is running, and its two addresses
Addresses are assigned and printed for you. Nothing is written to /etc/hosts,
and Zeltro never asks for sudo to manage a project.
Shared services mean shared versions — one Postgres major for everything. If two projects genuinely need different major versions, per-project tooling like DDEV or Lando is the better fit. That is the trade this design makes on purpose.
Why this matters more with an AI agent#
An agent working in a directory has no idea what else is on the machine. Left to
itself it will pick a port, add a database container to the compose file, and
quietly collide with something you had running. A fixed environment — assigned
addresses, known services, a written AGENTS.md — is what keeps that from
happening, and it is a large part of why Zeltro is arranged this way.
Questions
Do I have to pick a port for anything?
No. Every project is assigned a container IP and a published port when it is
created. zeltro ps prints the local address and the LAN address for each one.
What is the difference between the two addresses?
The local one is the container's own IP on the Docker network — no port, because nothing else is on that address. The LAN one is your machine's IP plus the project's published port, which is what another device has to go through.
On macOS and Windows, Docker keeps containers inside a virtual machine and the
container IP is not reachable from the host, so the local address is
http://localhost:<port> instead. Zeltro detects this and prints the address
that actually works.
Can projects reach each other by name?
Yes, from inside containers. Every project joins the same Docker network and Docker's embedded DNS resolves project and service names on it. That is what makes cross-project calls and shared databases straightforward.
Your browser is not on that network, which is why it uses the address above rather than a name.
What if two projects need different Postgres versions?
They cannot have them — one shared instance is the trade. If that is a hard requirement, use per-project tooling for those projects, or keep the odd one out on its own stack.
Does this work with a project that has its own docker-compose.yaml?
Yes. Bundled database services are removed and the app is repointed at the shared
ones, with credentials written into .env. The original file is preserved as
docker-compose.upstream.yaml.
What about the databases themselves — do projects see each other's data?
They share the server, not the database. Each project gets its own database and its own credentials. Cross-project access is possible on purpose, which is what makes a microservice-ish setup easy, but nothing is shared by accident.
Checked 2026-08-22. Other products change often — if something here is out of date, tell us and it gets fixed.