name: zdev
description: | Use when this capability is needed.
metadata:
author: 0ploy
zdev - Local Development Environment
zdev manages Docker-based local dev environments. One command starts an entire project with HTTPS,
routing, shared services (mail, DB browser, Redis browser), and container isolation.
How it works: Each project has .zdev/config.yaml defining its services. zdev start creates
an isolated Docker network, starts containers, and connects shared services. Traefik routes HTTPS
to https://{name}.0ploy.dev (wildcard DNS to 127.0.0.1). On macOS, Mutagen provides
fast file sync.
Prerequisites
zdev version
CLI Reference
zdev create <template> [name]
zdev start
zdev start <service>
zdev start -q
zdev stop
zdev stop <service>
zdev restart
zdev restart <service>
zdev down
zdev down -v
zdev exec <service> <command>
zdev exec app bash
zdev step "Installing dependencies"
zdev logs [service]
zdev info / status / config
zdev open [project]
zdev rename <new-name>
zdev mail / db / redis
zdev services status
zdev services recreate
zdev link create <name>
zdev link join <name> <proj>[.<svc>] ...
zdev link leave <name> <member> ...
zdev link ls / status <name>
zdev link delete <name>
zdev create express my-app
zdev create nuxt4 my-app
zdev create myorg/my-template my-app
zdev create ./local-dir my-app
zdev create express my-app --branch dev
After zdev create: cd my-app && zdev setup
Project Configuration
Minimal config
name: my-app
services:
app:
image: node:22-alpine
command: corepack enable && pnpm install && pnpm dev --host 0.0.0.0
working_dir: /app
volumes:
- ${PROJECTPATH}:/app
routing:
port: 3000
mutagen:
ignore:
- node_modules
- .pnpm-store
Result: https://my-app.0ploy.dev with HTTPS, isolation, shared services.
Shared services: hostnames and access
Shared services have two access modes: web UI (browser) and container-internal (from app code).
| Service | Web UI URL | Container hostname | Container port | Purpose |
|---|
| Mailpit | mail.shared.0ploy.dev | mail | 1025 (SMTP) | Email catching |
| Adminer | db.shared.0ploy.dev | adminer | 8080 (HTTP) | Database browser |
| Redis Insights | redis.shared.0ploy.dev | redis-insights | 5540 (HTTP) | Redis browser |
| Traefik | router.shared.0ploy.dev | router | - | Routing dashboard |
From app containers (e.g., configuring mail in your app):
- SMTP:
mail:1025 (no auth, no encryption) - all outgoing mail is caught by Mailpit
- Database: use the project service name (e.g.,
db:5432 for postgres), NOT adminer
- Redis: use the project service name (e.g.,
redis:6379), NOT redis-insights
From browser (web UIs):
- URLs follow the pattern
https://{service}.shared.0ploy.dev (or http:// without TLS)
- If a shared service isn't running, the URL redirects to the docs page (this is normal)
zdev mail, zdev db, zdev redis open the web UIs directly
Important distinction: Adminer and Redis Insights are browsers - they connect to your
project's own database/Redis services. They don't provide a database or Redis themselves. Your app
connects to its own db or redis service, not to adminer or redis-insights.
Full config
name: my-project
variables:
DB_PASSWORD: postgres
DB_NAME: ${PROJECTNAME}
shared:
router: true
mail: true
db: true
redis: true
environment:
APP_ENV: dev
services:
app:
image: node:22-alpine
command: corepack enable && pnpm install && pnpm dev --host 0.0.0.0
working_dir: /app
volumes:
- ${PROJECTPATH}:/app
environment:
DATABASE_URL: postgres://postgres:${DB_PASSWORD}@db:5432/${DB_NAME}
Key concepts
Variables (variables:) are substituted as ${VAR} throughout the config file. NOT passed to
containers. Use for values shared across services (DB passwords, names).
Environment - project-level environment: goes to ALL containers. services.<name>.environment
goes to THAT container only and overrides project-level.
Built-in variables: ${PROJECTPATH}, ${PROJECTNAME}, ${PROJECTDIR}, ${ZDEV_DOMAIN}, ${ZDEV_HOME} + all host env vars.
Volumes - bind mounts (start with /, ., ${) sync host dirs into containers. Named volumes
(just a name like db_data) are Docker-managed persistent storage. Named volumes auto-discovered,
persist across stop/start, removed with down -v.
Routing - HTTP: automatic HTTPS subdomain. TCP: { protocol: tcp, port: 5432, host_port: 5432 }
exposes raw TCP on host (for DB tools). Services within a project reach each other by name.
Multiple routed services in one project - every routing: block defaults to the project's
single domain. A second routed service (e.g. RabbitMQ management UI, a separate admin app) will
collide on the same host. Give it a distinct hostname via routing.domain:
services:
rabbitmq:
image: rabbitmq:3-management-alpine
routing:
port: 15672
domain: rabbitmq.${PROJECTNAME}.${ZDEV_DOMAIN}
Mutagen (macOS) - fast file sync. Always ignore dependency dirs (node_modules, vendor) and
build artifacts. Without ignores, installs take 5-10x longer.
command: is wrapped in sh -c — zdev runs the value of command: as a shell command, not
as a Docker CMD array. Bare --flag arguments meant for the image's default entrypoint fail with
sh: 0: Illegal option -- and the container exits silently. If you need to pass flags to the
image's binary (e.g. MariaDB tuning), either invoke the binary explicitly (exec mariadbd --group_concat_max_len=320000 …) or drop a config file via a volume mount. Symptom: container
status is Exited (2) and zdev logs db shows sh: 0: Illegal option --.
For stack-specific config examples, read references/config-examples.md.
Custom Commands (Justfiles)
.zdev/commands/*.just files become zdev subcommands. The filename is the command name.
Uses just syntax. default recipe runs when no recipe specified.
.zdev/commands/
setup.just -> zdev setup
test.just -> zdev test [recipe]
# .zdev/commands/test.just
default:
zdev exec app pnpm test
watch:
zdev exec app pnpm test --watch
Justfiles run on the host. Always use zdev exec for container commands.
Transparent forwarding of colon-namespaced subcommands. To wrap CLIs like bin/console cache:clear or artisan migrate:fresh, declare a recipe named after the .just file. zdev
auto-prepends the recipe name to the just invocation, so args with colons (which just otherwise
rejects as module-path syntax) pass through as recipe parameters:
# .zdev/commands/console.just
console *args:
zdev exec app php bin/console {{args}}
Now zdev console cache:clear → bin/console cache:clear. Without a filename-matching recipe,
the legacy behavior holds (first arg is the recipe name), so simple multi-recipe files like the
test.just above keep working unchanged.
Linking projects
When two zdev projects need to reach each other (a monolith calling a local microservice, split
front-end/back-end, shared gateway), create a named link — a shared Docker network any project
can join. zdev link create/join/status/leave/delete manages them. Cross-project DNS is
<service>.<project>.zdev (FQDN, not the short alias), and cross-project URLs are plain HTTP to
the internal port, not https://*.0ploy.dev (that wildcard resolves to the container's
own loopback). For the full details (DNS rules, URL rules, persistence across restarts, per-service
granularity), read references/linking.md.
Setting Up zdev for an Existing Project
Different from template scaffolding: the source code is already on the host, so the container's
command: doesn't need the .setup-complete wait loop — just install deps and exec the dev
server. setup.just is optional; only create one if there's a multi-step onboarding (migrations,
seed data, asset build).
- Read the existing stack.
package.json / composer.json / requirements.txt for framework
and dev command. .env / .env.example for DATABASE_URL, MAILER_DSN, HOST, PORT,
APP_URL. Existing docker-compose.yml / Dockerfile as a hint (ports, volumes) — not source
of truth. README for any manual setup steps.
- Pick a starting config from
references/config-examples.md matching the stack.
- Bind the dev server to all interfaces in the
command: — otherwise the container port isn't
reachable from Traefik: HOST=0.0.0.0 (Node), --host 0.0.0.0 (Vite), --allow-all-ip
(Symfony CLI), 0.0.0.0 (Django runserver).
- Rewire env for the zdev network. Change
DATABASE_URL host from localhost / 127.0.0.1
/ compose's service name to the zdev service name you declared (commonly db). Set
MAILER_DSN: "smtp://mail:1025" so outbound mail is caught by Mailpit. For any Symfony /
Sylius / Shopware / Laravel project, also set SYMFONY_TRUSTED_PROXIES: private_ranges now —
without it, the debug toolbar and login flows break behind Traefik (see Debugging → "Symfony/Sylius
behind Traefik"). Laravel equivalent: TRUSTED_PROXIES=*.
- Mirror the existing DB (image, version, credentials, database name) — the app's
.env
usually tells you all three.
mutagen.ignore for dependency and build artifact dirs: node_modules, .pnpm-store,
vendor, var/cache, .nuxt, .next, framework-specific build output. Without this, installs
are 5–10× slower on macOS.
.gitignore: add .zdev/local/ (always) and .pnpm-store/ (pnpm projects). .zdev/local/config.yaml deep-merges on top of before variable substitution, so per-developer secrets and overrides go there (e.g. referenced as in the committed config).
For stack-specific landmines when writing the config or entrypoint (Node corepack, pnpm build
scripts, PHP memory_limit, PHP extensions, Symfony TRUSTED_PROXIES, Webpack Encore / Vite
rebuild, mailer DSN), read references/stack-gotchas.md — those patterns apply whether you're
wrapping an existing repo or authoring a template.
Debugging
Container crashes: zdev logs -f app to see why. zdev restart app bounces just that one
container; zdev restart does the whole project. zdev down && zdev start for a full clean restart.
Config changes aren't taking effect: zdev restart (with or without a service name) just
stops+starts the existing container, so edits to environment:, image:, command:, routing, or
volume mounts don't apply. Use zdev update — it diffs the config against the running containers
and recreates only the services that actually changed. Code changes are live via bind mount /
Mutagen — no restart or update needed.
Redirects to docs page: Either the container isn't running or routing.port doesn't match the
app's port. For shared service UIs (mail, db, redis), also check zdev services status - the
service needs to be running AND the project must have the corresponding shared.* option enabled.
File sync issues (macOS): zdev mutagen status / zdev mutagen reset
DB connection refused: Use service name (db), not localhost. Example: postgres://postgres:postgres@db:5432/app
Can't reach https://other-project.0ploy.dev from inside a container: *.0ploy.dev
is a wildcard that resolves to 127.0.0.1 — from inside a container that's its own loopback, not
the host's Traefik. Container-to-container traffic must go HTTP-direct using the container name:
http://<service>.<project>.zdev:<internal-port>. Both projects must be joined to a shared link
(see "Linking projects").
Symfony/Sylius behind Traefik — stuck "Loading…" debug toolbar, broken admin login, mixed-content
errors in the console: Traefik terminates TLS and forwards HTTP to the app. Without trusted-proxy
config, Symfony can't tell the outer request was HTTPS and generates http:// URLs inside the HTTPS
page — the browser blocks them. Fix: add SYMFONY_TRUSTED_PROXIES: private_ranges to the app service
environment: (Symfony 6.3+ shorthand for RFC1918 + 127.0.0.1). Laravel equivalent: TRUSTED_PROXIES=*
for the TrustProxies middleware. Any framework that generates absolute URLs needs similar awareness.
curl 200 OK isn't enough for HTML apps: mixed-content, CSP failures, and JS errors are
browser-only failure modes — curl can't see them. After finishing a web template or UI change, open
the project in a browser via the chrome-devtools MCP tools (new_page, list_console_messages,
list_network_requests) and confirm the console is clean and all requests are https://.
Why .pnpm-store must be ignored: pnpm creates a ~500MB platform-specific store. If synced,
wrong binaries (glibc vs musl) break the container on image changes.
Creating zdev Templates
Templates enable zdev create <template> my-app for one-command project scaffolding.
For template authoring: read references/templates.md for the .setup-complete pattern,
scaffolding strategies, and setup.just conventions. For the stack-specific runtime behaviors the
template entrypoint must handle (Node, PHP, PHP framework landmines), read
references/stack-gotchas.md. Key concepts:
.setup-complete marker pattern - solves the container startup vs setup circular dependency.
Container waits in a loop until setup creates the marker, then starts the app.
setup.just runs on the host with zdev exec for container commands. Interactive terminal
means framework prompts work (unlike the container entrypoint which has no TTY).
@zdev step "<msg>" for phase headers in setup.just, not @echo. Setup output is a wall of
apk/composer/npm noise; plain echo disappears in it. zdev step prints two blank lines + cyan
▶ + bold so each phase stands out. Auto-plain on non-TTY / NO_COLOR.
- Scaffold in-place (
--force) when the framework supports it (Nuxt). Scaffold in /tmp and
copy back when the tool requires an empty dir (Symfony) - safe for PHP, not for Node.js/pnpm.
pnpm approve-builds --all after install for native module prebuilt binaries (pnpm v10).
npx nuxi prepare for Nuxt to trigger module dependency prompts during setup (not at runtime).
Test locally: zdev create ./my-template test-app && cd test-app && zdev setup
Source: 0ploy/zdev — distributed by TomeVault.