← Back to stories
· 13 min read

Zento: the CLI I built to stop fighting N Magento environments

Four design decisions behind a Go CLI for local Magento environments: layered compiled config, a single ingress with real TLS, tax-free Xdebug and idempotent onboarding.

magentodockergodx

There was one specific week that made me decide to build a tool. Three active Magento projects at once, for several clients, each with its own PHP version, its own combination of services, and its own docker-compose folder cloned from some earlier project and mutated by hand. On Monday, two stores fighting over port 443. On Wednesday, a bug that only happened in one project because its nginx.conf had silently diverged from the rest months earlier. On Thursday, half a day lost bringing up a project I hadn’t touched in a quarter.

None of those problems were interesting. All of them were avoidable. Out of that week came Zento: a Go CLI that manages local Magento environments on Docker, and is now the tool I use to bring up any project, new or inherited, in minutes. This post is not a tutorial (the tool isn’t public yet, more on that at the end): it’s a tour of the four design decisions that changed my day-to-day the most, told so you can steal the ideas even if you never use Zento.

The problem: N stores at once

The local environment for a single Magento project is a solved problem. The real problem shows up with the plural: when you work on several projects per week, each with its own Magento version (and therefore PHP version), its own search engine, Varnish or not, and a multi-gigabyte database.

Copying the Docker folder from the previous project and tweaking it is the default solution, and it has two costs that grow with every project:

  • Silent divergence. Every copy is a fork with no upstream. The FPM fix you made in project A never reaches project B; six months later you have five versions of nginx.conf and none of them is “the good one”.
  • Resource collisions. Two projects can’t publish the same port. You start inventing schemes (8081, 8082, 33061…) that nobody remembers and that end up in an outdated README.

And there’s a third, subtler cost: onboarding. Coming back to a dormant project, or bringing someone new in, meant a manual list of steps living half in a wiki and half in someone’s memory. Every manual step is a place where the process breaks differently each time.

Why another tool

I didn’t start from scratch, and I don’t claim to have invented the genre. markshust/docker-magento is the ecosystem’s reference and was my starting point for years: for one project, it works very well. Warden attacked the multi-project case before I did, with a global proxy I respect a lot. And DDEV solves the generic PHP case with real maturity.

The individual ideas existed. What I couldn’t find was a tool that modeled the problem the way I was living it: projects whose Docker config is versioned next to the code but not copied, internal environments (dev, staging, production replicas) that are independent workspaces with their own git clone and their own database, and onboarding that is a command rather than a document. That gap between “the pieces exist” and “the full model exists” is the only honest justification I know for building your own tooling.

Building an internal tool is justified when the mental model you need doesn’t exist in the ecosystem; not when you’re missing a feature, because features can be contributed.

Decision 1: config is compiled, not copied

The decision that defines Zento: no file that Docker Compose reads is ever edited by hand. The project versions a source (.docker_config/: Nginx/PHP/Varnish templates with variables, layered .env files, environment definitions), and a compilation step turns it into a generated artifact (.compiled/, outside git) which is the only thing Compose touches.

The compilation merges four layers of variables, in increasing order of priority:

  1. Base: defaults for all environments (env/php.env).
  2. Per-environment override: what dev or staging change (env/dev/php.env with xdebug on and opcache off, for instance).
  3. Services: variables generated automatically when optional services are enabled (zento service opensearch on).
  4. Computed: what the CLI calculates (source code paths, database name, volumes).
PRIORITY: LAST LAYER WINS base*.env per-envoverride services.env(generated) ZENTO_*(computed) zentocompile-env .compiled/ dockercompose
Versioned config compiles into what Compose actually reads: four layers merge in order, the lowest one wins.

What this buys isn’t elegance: it’s that divergence becomes impossible to hide. Shared config lives in common templates; whatever a project changes is explicit in its override layer, in a diff of a few lines, instead of buried in copy number five of a 300-line file.

On top of that base sit environments as workspaces. Each environment of a project has its own independent clone of the repository (any branch, even the same one another environment has checked out), its own dedicated database, its own services and scripts. And an environment can inherit from another with parent:; every unset field cascades down from the ancestor:

# .docker_config/environments.yaml
default_env: dev
environments:
  production:
    branch: main
    dedicated_db: true
  dev:
    parent: production   # inherits config, services and scripts...
    branch: develop      # ...but works on a different branch
    shared_media: true   # and shares pub/media to avoid duplicating gigabytes

Inheritance is layered in the .env files too: if a child’s php.env contains only PHP_MEMORY_LIMIT=2G, it keeps everything else from the parent. “Same environment as production but with more memory and a different branch” is declared in three lines, and understood by reading them.

Decision 2: one ingress for everything

Zento’s networking rule is radical: no container publishes ports to the host by default. Not the web, not the database, not Redis. The entry point is a single Traefik shared by all projects, living in ~/.zento/proxy/, started automatically when the first project runs zento start and shut down when the last one stops.

TLS PASSTHROUGH~/.ZENTO/PROXY Browser Traefik:443 (SNI) nginxstore-a nginxstore-b nginxstore-c
One ingress for every project: Traefik routes by SNI without terminating TLS; each nginx presents its own wildcard signed by the local CA.

The detail I like most in this piece is how it handles TLS: Traefik routes by SNI with passthrough, meaning it looks at the handshake’s hostname and forwards the bytes without decrypting them. Each project terminates TLS in its own Nginx, with a wildcard certificate signed by a per-user local CA (~/.zento/ca/). You trust that CA once per machine (zento certs trust installs it into the OS and browser trust stores) and from then on every project, present and future, has real HTTPS without warnings. The proxy doesn’t even hold certificates: there is nothing to renew or sync at the central point.

What about TCP services, when you want to connect a database client from the host? Explicit opt-in:

zento expose db      # publishes db on 127.0.0.1:<port derived from the project>
zento expose         # lists what's exposed
zento unexpose db    # stops publishing

The port is derived from the project, so two projects never collide and nobody maintains a port table in a README. The port 443 collision I opened this post with isn’t so much solved as made impossible: nobody competes for the host anymore.

Decision 3: Xdebug without paying the tax

Xdebug has a high runtime cost even when no debugging session is active, and the usual answer is binary: either it’s on and the whole environment crawls, or it’s off and turning it on means restarting containers right when you’ve found the bug.

Zento runs two identical PHP-FPM pools, one with Xdebug and one without, and lets Nginx choose per request, based on the cookie that browser extensions like Xdebug Helper already use:

# No cookie -> FPM without Xdebug. Cookie -> FPM with Xdebug.
map $cookie_XDEBUG_SESSION $fastcgi_pass {
  ""      fastcgi_backend;          # socket of the no-xdebug pool
  default fastcgi_backend_xdebug;   # socket of the xdebug pool
}

The browser where you enabled the extension debugs; all other traffic (the frontend you keep reloading, the crons, your teammates if they share the environment) keeps going through the fast pool. Toggling is one click, with nothing to restart. It’s the shortest section of this post and probably the easiest idea to adopt tomorrow in any stack: I inherited it from the ecosystem, refined it, and can’t imagine going back.

Decision 4: onboarding is an idempotent pipeline

The success metric I set for Zento was concrete: from git clone to a browsable store with real data, touching nothing by hand. The full sequence for joining an existing project:

zento init my-store --git-repo git@gitlab.example:org/magento.git
cd my-store
zento start --build     # brings up the stack
zento before-import     # composer install, creates the DB, generates env.php
zento db-cloud-dump     # downloads and imports the dump (or: zento db-import dump.sql.gz)
zento after-import      # setup:upgrade, URLs to local, reindex, cache flush
zento doctor            # verifies everything is healthy

Two properties make this work in the real world and not just in the demo:

  • Idempotency. before-import and after-import are re-runnable: if the dump fails mid-download or setup:upgrade blows up on a module, you fix it and run the same command again. An onboarding pipeline that isn’t idempotent is a list of manual steps wearing an automation costume.
  • Per-project hooks. Every command fires pre- and post- config-scripts if they exist in the project’s repo. That’s where the specifics live (sanitizing data, configuring sandbox payment credentials, admin tweaks) without touching the CLI. The tool defines the skeleton of the process; each project injects its own flesh. That boundary is what keeps the CLI from accumulating if (project == X) until it dies.

The final doctor isn’t decorative: it checks containers, sockets, DNS, certificates and Magento state, and turns “works for me, I think” into a verifiable list. When onboarding is one command, returning to a dormant project loses its activation cost, and that vanished friction changes even which work you’re willing to take on.

What didn’t make the cut

Four decisions don’t exhaust the tool. Left out, each deserving its own space: the deploy pipeline with customizable phases, multisite URL rewriting when importing dumps, optional services via Compose profiles (Varnish, OpenSearch, RabbitMQ, MailCatcher toggle with one command and every subcommand respects them), the custom PHP 8.1–8.4 images with the SPX profiler baked in, and an MCP server so AI agents can operate the environments with the same guarantees as a human. Several of these are worth a post of their own.

Closing

Zento is not the interesting part of my work: it’s what makes the interesting part start sooner. That’s the thesis I’d defend for any internal tooling: it’s justified when the mental model you need doesn’t exist in the ecosystem, and it pays for itself on every onboarding, every context switch, and every bug that can no longer exist by construction. The four decisions in this post (compiled config, single ingress, per-request debugging, idempotent pipelines) hold outside Magento and outside Docker; they’re the stealable part.

And the promise: Zento will be open source. There’s no date yet: I want to polish the documentation and the installation path before it has users who don’t know me. But the decision is made. If you want to know when it happens, subscribe to the RSS or reach out: it also helps me to know which part interests you most, because that decides what I document first.

Meanwhile, the series of Magento production war stories continues: next up is how to offer three simultaneous delivery methods (store pickup, same-day and standard shipping) on top of MSI, with time-window stock reservations.