Ever seen a developer and an AI agent fall in love with a dev environment? 🧑💻🤖❤️
zdev is a local development tool that gets you from git clone to coding in seconds. One command starts your entire project - HTTPS, routing, shared services, and secrets management. Simple enough for any AI coding agent to operate, powerful enough for complex multi-service setups.
cd my-project
zdev start
# Your project is running at https://my-project.0ploy.dev
0ploy.devis a wildcard DNS pointing to127.0.0.1- everything runs locally on your machine. No cloud, no accounts. You can use your own domain too.
Requires: a Docker engine. zdev talks to Docker through the docker CLI and the active docker context, so it works with Docker Desktop, OrbStack, or Colima on macOS, and Docker Engine on Linux. On macOS, OrbStack is the fastest option and Colima is a free, no-GUI alternative - see ALTERNATIVE_BACKENDS.md for a benchmark and trade-offs.
Every project runs in its own isolated network. zdev gives each project its own HTTPS subdomain - no port conflicts, no SSL setup. Shared services like mail catching, database browsing, and Redis inspection are available to all projects automatically.
Important
Your code runs in containers, not on your machine. Every pnpm install, composer install, and dev server runs inside an isolated Docker container. If a malicious npm package tries to steal your SSH keys, read your browser cookies, or encrypt your files - it can't. It's trapped in a throwaway container with no access to your host. In an era where supply chain attacks on npm, PyPI, and Packagist are increasingly common, this isn't just convenience - it's protection.
Your secrets live in 1Password, not in .env files. The config you commit holds only an Environment ID and op-env:// references - real values stay in your team's shared 1Password Environment and are injected when containers are created. No .env handed around over Slack, no plaintext copies drifting on every laptop, nothing to leak when the repo, a backup, or an AI agent reads your config. See Team Secrets from 1Password.
zdev gives AI coding agents (Claude Code, Cursor, Copilot) exactly what they need: deterministic environments with zero ambiguity.
- One command -
zdev startis all the agent needs. No multi-step setup to get wrong. - Predictable URLs - The app is always at
https://{name}.0ploy.dev. No port guessing. - Single config file -
.zdev/config.yamlis the complete source of truth. One file to read, not five. - Discoverable commands -
ls .zdev/commands/reveals all project-specific tasks. No guessing. zdev exec app <cmd>- Run anything in any container. No container name lookup needed.- No secrets in the loop - config holds Environment IDs and
op-env://references, not values. Agents read, edit, and commit the full config without ever handling secret material.
Install the zdev skill so your agent knows how to use the dev environment:
npx skills add 0ploy/zdevThis teaches your agent the full zdev CLI, config format, debugging workflows, and project setup patterns. Your agent can also help you create custom zdev templates.
| Without zdev | With zdev |
|---|---|
| Port conflicts between projects | Every project gets its own HTTPS subdomain |
| Each project configures its own mail, DB tools | Shared services run once, work for all projects |
| New developer spends a day setting up | Clone, zdev start, done |
| Complex Docker Compose with 100+ lines | Simple config with sensible defaults |
Secrets passed around in .env files and Slack |
Config references 1Password - commit it, clone it, zdev start |
| Slow file sync on macOS | Native-speed file sync, zero config |
| Malicious packages can access your entire machine | Code runs in isolated containers - supply chain attacks stay sandboxed |
curl -fsSL https://raw.githubusercontent.com/0ploy/zdev/main/install.sh | shThe installer places the real binary at ~/.zdev/bin/zdev and symlinks it into /usr/local/bin (one-time sudo prompt). After that, zdev checks GitHub at most once per 24 hours during command startup. When a new release exists it announces the update, downloads and verifies it synchronously, then installs it into the user-owned canonical path. The current command keeps running its in-memory version; the next invocation uses the new binary. Set ZDEV_NO_UPDATE_CHECK=1 to disable. zdev self-update still works for on-demand updates and auto-migrates legacy installs to the symlink layout.
This installs SSL certificates and starts the shared services (router, mail catcher, DB browser):
zdev systemcheckThe fastest way is to use a template:
zdev create express my-app
cd my-app
zdev setupOpen https://my-app.0ploy.dev - that's it. HTTPS works out of the box.
Or create a project manually with a config file at my-app/.zdev/config.yaml:
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
- .nuxt${PROJECTPATH} is resolved automatically to your project's absolute path. Other available variables: ${PROJECTNAME}, ${PROJECTDIR}, ${ZDEV_DOMAIN}.
cd my-app
zdev startCreate new projects from starter templates with zdev create:
zdev create express my-app # Express.js
zdev create nuxt4 my-app # Nuxt 4
zdev create symfony my-app # Symfony
zdev create myorg/my-template my-app # Any GitHub repoSome templates scaffold the project during zdev create itself (via a .zdev/scaffold.sh hook) - for those, cd my-app && zdev start is all that's left. Others do their setup in a zdev setup step. Each template's README (and the next-steps zdev create prints) tells you which.
Browse all available templates on GitHub: 0ploy repositories matching zdev-template-. Each template's README explains what it includes and how to use it.
Want to create your own template? See the Template Authoring Guide.
These run once and are shared across all your projects. No per-project configuration needed.
| Service | URL | What it does |
|---|---|---|
| Router | https://router.shared.0ploy.dev |
Routing dashboard - see all routes |
https://mail.shared.0ploy.dev |
Catches all outgoing email (Mailpit) | |
| DB | https://db.shared.0ploy.dev |
Browse any project's database (Adminer) |
| Redis | https://redis.shared.0ploy.dev |
Inspect Redis keys and data (Redis Insights) |
| Logs | https://logs.shared.0ploy.dev |
Tail container logs across all projects (Dozzle), grouped per project |
Connecting from your app containers: Configure your app to send mail to mail:1025 (SMTP, no auth). For databases and Redis, use your project's own service names (e.g., db:5432, redis:6379) - Adminer and Redis Insights are browser UIs, not the services themselves.
Log retention: Dozzle is a viewer, not a store. Logs come from Docker's per-container ring buffer (default in Docker Desktop: ~50 MB rotated, ~5 files), so they survive zdev down and Docker Desktop restarts but are lost when a container is recreated - that includes zdev update (on config drift), zdev remove, and zdev services recreate. To grow the per-container buffer, set log-opts: { max-size, max-file } in Docker Desktop's daemon JSON. Persistent log history across recreates needs a separate log shipper (Loki, Vector, etc.) and is out of scope for zdev.
Per-project visibility: Dozzle only shows containers from projects that opt in via shared.logs: true. Projects without it stay hidden, even though Dozzle has full Docker socket access. Shared service containers (router, mail, db, redis, logs) are always visible.
In-container shell is off by default. Dozzle can open a shell into any container it sees, but the router publishes ports on all interfaces, so on an untrusted network (shared office, coffee-shop Wi-Fi) that shell is reachable by anyone who can hit the host. Enable it only on a network you trust by setting shared.logs.shell: true in ~/.zdev/global-config.yaml (changes take effect on the next zdev services recreate or zdev start).
Open them directly:
zdev mail # open Mailpit
zdev db # open Adminer
zdev redis # open Redis Insights
zdev logs --open # open Dozzle log viewerPer-project opt-in/out. router, mail, and logs are connected to every project by default; db and redis are opt-in. Override individual fields under shared: in .zdev/config.yaml; missing fields keep their defaults.
# .zdev/config.yaml
shared:
db: true # opt in (default: false)
redis: true # opt in (default: false)
mail: false # opt out (default: true)
# router, logs not listed -> stay at default trueEvery project and shared service gets locally-trusted HTTPS certificates. Your browser shows a green lock, cookies work with Secure flag, and your local environment matches production.
zdev's URLs work because *.0ploy.dev is a public wildcard record pointing at 127.0.0.1. Some routers refuse to return answers that point at loopback ("DNS rebinding protection"), so https://my-app.0ploy.dev fails to resolve on those networks.
zdev systemcheck detects this automatically and offers to fix it. You can also run it yourself:
zdev dns enable # one sudo prompt to configure the host resolverThis runs a tiny local DNS container and points your OS at it for the zdev domain only (via /etc/resolver on macOS, systemd-resolved on Linux) - the router is bypassed for that domain and all other DNS is untouched. zdev dns status shows the current state; zdev dns disable reverts it.
The fallback covers your global zdev domain and every subdomain under it (so all your projects are handled by a single zdev dns enable). A project that overrides its domain: to a different base domain is not covered - it would still need that domain to resolve to 127.0.0.1 by other means.
Once enabled, the DNS container is a shared service like the router: it shows up in zdev services status and zdev status, and zdev services start, stop, and recreate manage it alongside the others. On machines that don't need the fallback it stays invisible. Note that zdev services stop stops it too, so your zdev URLs won't resolve until the next zdev services start or zdev start - to turn the fallback off permanently use zdev dns disable, which reverts the host resolver config so normal DNS takes over.
The classic onboarding wall: the app needs API keys and passwords, so someone digs up the current .env and sends it over Slack - and from that moment every developer has their own drifting, plaintext copy. zdev removes the handoff entirely: secrets live in a 1Password Environment your team already shares, and the committed config only holds references.
variables:
op-env: b7qmzx3kfpwj4hn2c6t8vydl5a # your team's 1Password Environment (ID is not secret)
services:
app:
op-env: ${op-env} # injects every variable of the Environment
environment:
API_URL: https://example.com/api # plain values mix freely - and win over injection
SENDGRID_KEY: op-env://${op-env}/SENDGRID_KEY # or inject single variables, from any EnvironmentA new teammate clones the repo, runs zdev start, approves one Touch ID prompt - done. No .env to obtain, nothing secret in git. When the Environment changes in 1Password - rotated values or newly added variables - zdev update --refresh-secrets recreates exactly the services affected. Manage the variables as a table in the 1Password app (Developer > Environments), including one-click .env import for existing projects.
Day-to-day commands (start/stop/restart/status) never contact 1Password, so there are no surprise authorization prompts. Requires the beta 1Password CLI - zdev offers to install it and walks you through sign-in when needed. See the full reference for CI setup and details.
File sharing between your host and containers is notoriously slow on macOS. zdev automatically syncs files at native speed - no configuration needed. On Linux this isn't needed (already fast).
How much difference does it make? We benchmarked a Nuxt 4 app with ~1000 dependencies:
| Approach | pnpm install | Cold start to app ready |
|---|---|---|
| Docker bind mount (default macOS) | 34.6s | ~42s |
| zdev with file sync | 6.7s | ~17s |
| zdev warm restart (stop + start) | 2.4s | ~2s |
That's a 5x speedup on cold start and instant warm restarts. The trick: zdev syncs your source code via fast file sync, while keeping node_modules and other generated files inside the container where filesystem operations are native speed.
This holds regardless of Docker engine. Even on OrbStack, whose bind mounts are much faster than Docker Desktop's, writing node_modules to a native volume is still ~2x faster than to a bind mount - so file sync stays on by default on every macOS engine. See ALTERNATIVE_BACKENDS.md for the cross-engine numbers.
Exclude paths you don't need synced back to the host:
mutagen:
ignore:
- node_modules
- .pnpm-store # pnpm's content-addressable store - platform-specific, don't sync
- .nuxt
- .output
- var/cacheImportant: Always add .pnpm-store to the ignore list for pnpm projects. pnpm creates its package store inside the project directory when running in a container. Without ignoring it, ~500MB of platform-specific binaries sync to the host, causing slow syncs and broken native modules when switching images.
By default, all HTTP services in a project share the project's domain. For projects with multiple web services (frontend + backend, app + admin), you can assign each service its own domain using routing.domain:
name: my-app
services:
frontend:
image: node:22-alpine
command: pnpm dev --host 0.0.0.0
working_dir: /app
volumes:
- ${PROJECTPATH}/frontend:/app
routing:
port: 3000
# Uses project domain: my-app.0ploy.dev
backend:
image: node:22-alpine
command: pnpm dev --host 0.0.0.0
working_dir: /app
volumes:
- ${PROJECTPATH}/backend:/app
routing:
port: 4000
domain: api.${PROJECTNAME}.${ZDEV_DOMAIN}
# Uses custom domain: api.my-app.0ploy.devThe domain field supports variable substitution and only applies to HTTP/HTTPS routing (not TCP/UDP).
Beyond HTTPS, zdev can expose raw TCP and UDP ports. This lets you connect to a database inside a project from your host using tools like DBeaver, pgAdmin, or psql:
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_PASSWORD: postgres
routing:
protocol: tcp
port: 5432 # container port
host_port: 5432 # exposed on localhost:5432psql -h localhost -p 5432 -U postgres # connect from your hostMultiple projects can expose different ports without conflicts. Works for MySQL, Redis, RabbitMQ, or any TCP/UDP service.
Bind mounts (${PROJECTPATH}:/app) sync your source code into the container. Edits on the host are reflected immediately. On macOS, zdev handles fast sync automatically via Mutagen. Add node_modules, .pnpm-store, and build caches to mutagen.ignore so they stay inside the container (fast) and don't sync back to the host.
Named volumes (db_data:/var/lib/postgresql/data) are persistent storage managed by zdev. Use these for data that must survive zdev down - database files, uploaded assets, SQLite databases:
volumes:
- ${PROJECTPATH}:/app # your source code (synced to host)
- db_data:/var/lib/postgresql/data # database files (persists across down)
- data:/app/data # SQLite, uploads, etc.Named volumes persist across zdev stop/zdev start AND zdev down. Only removed with zdev down -v. That command also removes project-scoped volumes left by services deleted from the config. No separate declaration needed - zdev discovers them automatically.
When a stock image isn't enough - you need system packages, a compiled binary, or a specific toolchain baked into the container - point the service at a Dockerfile instead of hand-rolling a build script:
services:
app:
dockerfile: .zdev/Dockerfile # build this instead of pulling an image
command: pnpm dev --host 0.0.0.0
volumes:
- ${PROJECTPATH}:/appzdev start builds the image automatically when it's missing or when the Dockerfile changed - so a fresh clone starts with zero manual steps. Editing source files never triggers a rebuild (source is bind-mounted live); Docker's layer cache keeps rebuilds fast when they do happen. zdev update treats a stale image as a config change and recreates the service with the rebuilt image.
The build context is always the project root: COPY/ADD paths in the Dockerfile resolve from there (regardless of where the Dockerfile lives), and a .dockerignore at the root keeps builds fast on large repos. The image is tagged zdev-<project>-<service>:latest by default; set image: alongside dockerfile: to choose the tag yourself. Force or skip building with flags on start and update:
zdev start --build # rebuild even if nothing changed
zdev start --no-build # never build; fails clearly if the image is missingdockerfile: is for dev containers only. Build args, multi-stage targets, custom contexts, multi-arch builds, build secrets, registry push/pull auth, and production images are intentionally not supported - for those, keep a custom build command (.zdev/commands/build-image.just) that produces the image: tag.
Every project has recurring tasks: install deps, run migrations, seed data, run tests. Instead of documenting these in a README, define them as just files in .zdev/commands/. The filename becomes the command:
.zdev/commands/
setup.just -> zdev setup
test.just -> zdev test
seed.just -> zdev seed
# .zdev/commands/setup.just
default:
zdev exec app pnpm ci
zdev exec app npx prisma db push
# .zdev/commands/test.just
default:
zdev exec app pnpm test
watch:
zdev exec app pnpm test -- --watchzdev setup # install deps + push schema
zdev test # run tests
zdev test watch # run tests in watch modeFor CLIs with colon-namespaced subcommands (cache:clear, migrate:fresh), declare a recipe named after the file - args pass through verbatim:
# .zdev/commands/console.just
console *args:
zdev exec app php bin/console {{args}}zdev console cache:clear -> bin/console cache:clear.
Agents can ls .zdev/commands/ to discover all available project tasks.
Each project runs in its own isolated network. Services within a project reach each other by name (db, redis, app), but projects can't see each other's services. The shared router bridges them to the outside.
zdev start # Start every service in the project
zdev start <service> # Start a single service (project setup runs idempotently)
zdev start --build # Force rebuilding dockerfile: images
zdev start --no-build # Never build; fail if a dockerfile: image is missing
zdev stop # Stop containers (keeps them for quick restart)
zdev stop <service> # Stop a single service container
zdev restart # Stop + start every service
zdev restart <service> # Bounce a single service container in-place
zdev update # Apply config changes and remove services deleted from config
zdev update --refresh-secrets # Also check 1Password and recreate services whose secrets changed
zdev down # Remove containers and network
zdev down -v # Remove everything including volumes
zdev rename <n> # Rename project, migrate volumes, restartzdev exec app bash # Shell into a container
zdev exec app pnpm test # Run a command
zdev logs # View logs
zdev logs -f app # Follow logs for a servicezdev info # Show project info, URLs, services
zdev list # List all projects
zdev config # Show resolved configuration
zdev status # Quick status check
zdev open # Open the current project's URL in the browser
zdev open my-app # Open another registered project's URLzdev services status # Check shared service status
zdev services start # Start shared services
zdev services stop # Stop shared services
zdev services recreate # Rebuild shared service containersLink networks enable direct container-to-container communication between separate projects. Each project runs on its own isolated Docker network, so by default containers in project A cannot reach containers in project B. Link networks solve this by creating a shared Docker network that selected containers join.
zdev link create <name> # Create a named link network
zdev link join <name> <member> [<member>...] # Add projects or services
zdev link leave <name> <member> [<member>...] # Remove members
zdev link delete <name> # Remove link and disconnect all
zdev link ls # List all links
zdev link status <name> # Show members and connection stateMembers can be whole projects or individual services:
zdev link create backend-mesh
zdev link join backend-mesh sec-scan sec-scan-decoder
zdev link join backend-mesh redis-debug.app # only the app serviceLinked containers reach each other by their container name, not the project domain:
# From inside sec-scan, reach sec-scan-decoder's app service:
curl http://app.sec-scan-decoder.zdev:3000Why container names, not project domains? The project domain (e.g., sec-scan-decoder.0ploy.dev) uses wildcard DNS that resolves to 127.0.0.1. Inside a container, 127.0.0.1 points to the container itself, not the host or Traefik - so the domain is unreachable. Container names (e.g., app.sec-scan-decoder.zdev) are resolved by Docker's built-in DNS, which returns the actual container IP on the shared link network. This works reliably and without TLS certificate issues.
The container name pattern is <service>.<project>.zdev - the same name shown by zdev link status.
Links are stored in the global state file and survive restarts - when a linked project starts, its containers are automatically reconnected to the link network. Each link creates its own Docker network (zdev_link_<name>), so different link groups stay isolated from each other.
Link names may only contain alphanumeric characters, hyphens, and underscores.
zdev mutagen status # Check sync status
zdev mutagen flush # Wait for sync to complete
zdev mutagen reset # Recreate sync sessions (if stuck)name: my-shop
variables:
DB_PASSWORD: root
DB_NAME: ${PROJECTNAME}
services:
app:
image: webdevops/php-nginx:8.3
working_dir: /app
volumes:
- ${PROJECTPATH}:/app
environment:
WEB_DOCUMENT_ROOT: /app/public
DATABASE_URL: mysql://root:${DB_PASSWORD}@db:3306/${DB_NAME}
MAILER_DSN: smtp://mail:1025 # catch outgoing mail in Mailpit
SYMFONY_TRUSTED_PROXIES: private_ranges # Symfony/Sylius behind Traefik (see Troubleshooting)
routing:
port: 80
db:
image: mysql:8.0
volumes:
- db_data:/var/lib/mysql
environment:
MYSQL_ROOT_PASSWORD: ${DB_PASSWORD}
MYSQL_DATABASE: ${DB_NAME}name: my-api
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:postgres@db:5432/app
routing:
port: 3000
db:
image: postgres:16-alpine
volumes:
- db_data:/var/lib/postgresql/data
environment:
POSTGRES_PASSWORD: postgres
POSTGRES_DB: app
mutagen:
ignore:
- node_modules
- .pnpm-store
- .nuxtname: my-docs
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: 5173
mutagen:
ignore:
- node_modules
- .pnpm-storename: 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| Field | Type | Default | Description |
|---|---|---|---|
name |
string | directory name | Project name, used in domain and container names |
domain |
string | {name}.0ploy.dev |
Project domain for HTTP routing |
variables |
map | - | Reusable ${VAR} placeholders substituted throughout the config (not passed to containers) |
environment |
map | - | Environment variables passed to ALL containers. Values may reference 1Password Environment variables (op-env://<environment-id>/NAME) |
shared.router |
bool | true |
Connect to shared Traefik router |
shared.mail |
bool | true |
Connect to shared Mailpit |
shared.db |
bool | false |
Connect to shared Adminer |
shared.redis |
bool | false |
Connect to shared Redis Insights |
shared.logs |
bool | true |
Connect to shared Dozzle log viewer |
mutagen.ignore |
list | - | Paths excluded from file sync (macOS). Mutagen itself is configured globally, see below |
| Field | Type | Default | Description |
|---|---|---|---|
image |
string | required unless dockerfile: is set |
Docker image (with dockerfile:, the tag the build produces) |
dockerfile |
string | - | Build a local dev image from this Dockerfile (path relative to the project root, which is also the build context). Auto-builds on start when missing or changed |
command |
string | - | Container command |
working_dir |
string | - | Working directory inside container |
volumes |
list | - | Volume mounts (bind mounts and named volumes) |
environment |
map | - | Env vars for this container (overrides project-level). Values may reference 1Password Environment variables (op-env://<environment-id>/NAME) |
op-env |
string | - | 1Password Environment ID: injects every variable of that Environment into the container env. Explicit environment: entries win. Not secret, safe to commit |
routing.protocol |
string | http |
http, https, tcp, udp |
routing.port |
int | 80 (http), 443 (https) | Container port to route to |
routing.host_port |
int | - | Host port for TCP/UDP (required for tcp/udp) |
routing.domain |
string | project domain | Custom domain for this service (http/https only) |
Config loading validates project and service names, requires every service to set image: or dockerfile:, checks routing protocols and port ranges, rejects duplicate TCP/UDP host ports, and validates Mutagen file modes. Unknown fields are rejected in both project and global config files.
| labels | map | - | Docker labels |
| mutagen.user | string | - | Owner stamped on synced files inside the container (e.g. www-data). Use when the in-container process runs as a non-root user that must read/write the synced tree |
| mutagen.group | string | - | Group stamped on synced files inside the container |
| mutagen.file_mode | string | - | Octal mode for synced files (e.g. "0644") |
| mutagen.directory_mode | string | - | Octal mode for synced directories (e.g. "0755") |
Per-service Mutagen ownership is for images whose runtime user differs from root - PHP/Apache as www-data, Node as node, etc. Without it, Mutagen writes files as root and the container's process can't read them. Example:
services:
web:
image: my-php-apache:latest # apache runs as www-data
volumes:
- ${PROJECTPATH}:/var/www/html
mutagen:
user: www-data
group: www-dataWhen you change any of these values, zdev detects the drift, recreates the sync session, and runs chown -R inside the container so pre-existing files pick up the new ownership too. (Mutagen's own defaults only stamp newly-created files.)
variables define ${VAR} placeholders substituted throughout the config file. They are NOT passed to containers. Use them to avoid duplicating values like database passwords across services.
environment (project-level) is passed to ALL containers. services.<name>.environment is passed to that specific container and overrides project-level values with the same name.
Built-in variables: ${PROJECTNAME}, ${PROJECTPATH}, ${PROJECTDIR}, ${ZDEV_DOMAIN}, ${ZDEV_HOME}, ${USER}, ${HOME}, plus all host environment variables. User-defined variables can reference built-in ones (e.g. DB_NAME: ${PROJECTNAME}_db).
The concept and a full example live in Team Secrets from 1Password. Two forms of one token, and the precise rules:
services.<name>.op-envholds a 1Password Environment ID (app: Developer > Environments > Manage environment > Copy environment ID) and injects EVERY variable of that Environment into the container env. Explicitenvironment:entries - plain values and references alike - always win over injected ones.op-env://<environment-id>/<VARIABLE>as an env value injects a single variable. Works in project-level and service-levelenvironment:; the env var name may differ from the variable name (DB_PASS: op-env://<id>/MYSQL_PASSWORD). References are self-contained, so one project can mix variables from multiple Environments.- Neither the ID nor the references contain secret material - both are safe to commit. Keep the ID DRY with
${VAR}substitution (it runs first, at config load):variables: {op-env: <id>}, thenop-env: ${op-env}andop-env://${op-env}/VARIABLE. - A reference must be the entire env value - no mid-string interpolation (
mysql://dev:op-env://<id>/DB_PASS@db/...does not work). For composite values like aDATABASE_URL, store the whole assembled string in the Environment. - Resolution happens only when a container is created; each distinct Environment is fetched in one cached
opcall per operation (at most one authorization prompt), and values are never written to disk by zdev.startof an existing container,restart,status, and no-opupdatenever contact 1Password. On recreates, secrets resolve BEFORE the old container is removed, so a failure leaves the running service intact. - Changes in 1Password are not auto-detected.
zdev update --refresh-secretschecks 1Password and recreates only the services whose values changed - rotated values and variables added to or removed from an attached Environment.zdev restartdoes NOT refresh secrets. - Requires the beta CLI build:
brew install 1password-cli@beta(2.33.0-beta.02+; Environments don't exist in the stable build). Ifopis missing, zdev offers the install; if you're signed out, it walks you throughop signin.zdev systemcheckreports CLI, Environments support, and sign-in state. - CI / non-interactive: create a service account with read access to the Environment and set
OP_SERVICE_ACCOUNT_TOKEN- zdev passes it through and never prompts when stdin isn't a terminal. - Values must be single-line; duplicate keys in the Environment resolve to the last occurrence.
- Like any container env var (docker-compose included), resolved values are visible to
docker inspecton your machine.
If .zdev/local/config.yaml exists, it is deep-merged on top of .zdev/config.yaml before variable substitution. Use it for per-developer settings that shouldn't be committed (machine-specific tweaks, override images, personal secrets - though for team secrets, prefer op-env:// references above). Add .zdev/local/ to .gitignore.
Merge rules:
- Maps merge recursively - e.g. add a single env var to a service without copying the rest.
- Scalars in local replace the base value.
- Slices in local replace the base slice entirely (no append). To tweak
volumes:ormutagen.ignore:, copy the full list.
Because the merge happens before substitution, you can put variables: in the local file and reference them with ${VAR} from the committed config:
# .zdev/config.yaml (committed)
services:
app:
environment:
STRIPE_KEY: ${STRIPE_KEY}
# .zdev/local/config.yaml (gitignored)
variables:
STRIPE_KEY: sk_test_abc123Applies to all projects. Auto-created on first run. Usually you don't need to touch this.
domain: 0ploy.dev
ssl:
enabled: true
mutagen:
enabled: auto # "auto" (macOS only), "true" (always), "false" (never)
sync_mode: two-way-safe # default sync mode
shared:
logs:
shell: false # enable Dozzle's in-container shell (default false; see below)Environment variables zdev itself honors: ZDEV_HOME relocates the zdev home directory (config, state, certs, downloaded tools; default ~/.zdev), ZDEV_PLAIN=1 forces plain output (no colors, hyperlinks, or step markers - overrides the terminal.plain config setting), ZDEV_NO_UPDATE_CHECK=1 disables the background self-update check, and OP_SERVICE_ACCOUNT_TOKEN authenticates 1Password secret resolution in CI.
0ploy.dev uses wildcard DNS pointing to 127.0.0.1. If it doesn't work:
- Check:
dig my-app.0ploy.dev - Corporate VPNs sometimes block external DNS - try a different network
- Add entries to
/etc/hostsas a workaround
zdev down # clean up
zdev start # try again
zdev logs -f app # check what's happeningzdev mutagen status # check if Mutagen is running
zdev mutagen reset # recreate sync sessions if stuckzdev uses ports 80 and 443 for the shared router. Check what's using them:
lsof -i :80
lsof -i :443Traefik terminates HTTPS and forwards plain HTTP to your app on its internal port. Without a
trusted-proxy config, Symfony (and Laravel) treat the inbound request as HTTP and generate
http:// URLs inside the HTTPS page - the browser blocks them as mixed content, so the Symfony
debug toolbar hangs on "Loading…" and features that redirect or build absolute URLs (admin login,
password reset emails, asset manifests) break.
Fix - add one env var on the app service:
services:
app:
environment:
SYMFONY_TRUSTED_PROXIES: private_ranges # Symfony/Sylius (RFC1918 + 127.0.0.1)
# Laravel equivalent:
# TRUSTED_PROXIES: "*" # for the TrustProxies middlewareThen apply with zdev update. Any framework that generates absolute URLs while running behind
a reverse proxy needs similar awareness.
zdev doesn't reinvent the wheel. It orchestrates proven open-source tools into a seamless experience - so you get the power without the configuration.
| Technology | What zdev uses it for | Link |
|---|---|---|
| Docker | Container runtime, network isolation | docker.com |
| Traefik | Reverse proxy - HTTPS routing, subdomains, TCP/UDP | traefik.io |
| mkcert | Locally-trusted SSL certificates | github.com/FiloSottile/mkcert |
| Mutagen | Fast file sync on macOS | mutagen.io |
| just | Command runner for project tasks | github.com/casey/just |
| Mailpit | Email testing - catches all outgoing mail | github.com/axllent/mailpit |
| Adminer | Database browser - MySQL, PostgreSQL, SQLite | adminer.org |
| Redis Insights | Redis browser - keys, queries, memory analysis | redis.io/insight |
| Dozzle | Container log viewer - per-project grouping in the browser | dozzle.dev |
Want to help improve zdev? See CONTRIBUTING.md for the developer guide - project structure, testing strategy, architecture decisions, and how to add new features.
Want to create a project template? See the Template Authoring Guide.
MIT
