- Introduction
- Basic Structure
- Deployment Process
- Template Expressions (
x-zane-env) - Service Configuration
- Routing and URL Configuration
- Volumes
- Docker Configs
- Networks
- Environment Variables
- Service Dependencies
- Complete Examples
- Dokploy Template Migration
- Advanced Patterns
- Troubleshooting
ZaneOps extends standard Docker Compose syntax with template expressions and automatic service orchestration. This guide covers everything you need to create production-ready compose templates for ZaneOps.
- Template expressions for generating secrets, domains, and service aliases
- Label-based routing for automatic HTTP/HTTPS configuration
- Automatic service name hashing to prevent DNS collisions
- Three-tier networking for service isolation and communication
- Config versioning for inline configuration files
- Variable interpolation using
${VAR}syntax in env vars and configs
- Stack: A docker-compose file containing one or more services
- Template expressions: Jinja2-like syntax in
x-zane-envfor generating values - Service hashing: All service names prefixed with
{hash_prefix}_to prevent DNS collisions - Lazy computation: Templates processed only during deployment, not on save
- Deployment method: Stacks are deployed using
docker stack deploy --with-registry-authfor automatic registry authentication
services:
app:
image: nginx:latestThis is the simplest valid ZaneOps compose template. ZaneOps will:
- Hash the service name:
app→abc123_app(whereabc123is the stack's hash prefix) - Inject the
zanenetwork for inter-service communication - Deploy using
docker stack deploy --with-registry-authfor automatic registry authentication - Create the service as a Docker Swarm service
# Optional: Docker Compose version (ignored by ZaneOps)
version: "3.8"
# ZaneOps template expressions (optional but recommended)
x-zane-env:
# Define stack-wide variables with template expressions
VAR_NAME: "{{ template_expression }}"
# Services definition (required)
services:
service_name:
image: image:tag
environment:
KEY: ${VALUE}
deploy:
labels:
# Routing configuration
zane.http.routes.0.domain: "example.com"
# Optional: Named volumes
volumes:
data:
# Optional: Custom networks (ZaneOps injects 'zane' network automatically)
networks:
backend:
# Optional: Inline configs
configs:
nginx_config:
content: |
server {
listen 80;
}ZaneOps uses Docker Swarm's stack deployment mechanism with automatic registry authentication:
docker stack deploy --with-registry-auth --compose-file <processed-compose.yml> <stack-name>Key deployment steps:
- Template processing:
x-zane-envexpressions evaluated, variables expanded - Service name hashing: All service names prefixed with stack's hash prefix
- Network injection:
zanenetwork added to all services - Config creation: Inline configs created as versioned Docker configs
- Stack deployment:
docker stack deploy --with-registry-authexecuted
This flag automatically shares registry credentials with Docker Swarm workers, enabling:
- Private registry support: Pull images from authenticated registries (DockerHub, GHCR, private registries)
- Automatic credential propagation: No manual registry login needed on worker nodes
- Secure credential handling: Credentials stored in Docker Swarm's encrypted storage
Example use cases:
- Private images from DockerHub:
myorg/private-app:latest - GitHub Container Registry:
ghcr.io/myorg/app:latestregistries:registry.company.com/app:latest
To use private images, configure registry credentials in ZaneOps:
- Via UI: Settings → Container Registry → Add Registry
- Via API:
POST /api/container-registries/ Content-Type: application/json { "url": "https://index.docker.io/v1/", "username": "myuser", "password": "mytoken" }
Once configured, ZaneOps automatically uses these credentials during docker stack deploy.
The x-zane-env section defines stack-wide environment variables using template expressions. These expressions are evaluated once during first deployment and values are persisted.
x-zane-env:
VARIABLE_NAME: "{{ template_function }}"
ANOTHER_VAR: "{{ template_function | argument }}"Important: Variables defined in x-zane-env must be referenced using ${VAR_NAME} syntax (with curly braces) to be interpolated in services, configs, and other parts of the compose file. Without the braces, the variable will not be expanded.
Generates a random username in the format {adjective}{animal}{number}.
x-zane-env:
DB_USER: "{{ generate_username }}"Output example: reddog65, bluecat42, happylion91
Generates a cryptographically secure random password as a hexadecimal string.
Requirements:
- Length must be even (divisible by 2)
- Minimum length: 8
x-zane-env:
DB_PASSWORD: "{{ generate_password | 32 }}"
API_SECRET: "{{ generate_password | 64 }}"
SHORT_TOKEN: "{{ generate_password | 16 }}"Output example:
32→a1b2c3d4e5f6789012345678abcdef0164→a1b2c3d4e5f6789012345678abcdef01a1b2c3d4e5f6789012345678abcdef01
Common mistakes:
# ❌ WRONG - odd length
PASSWORD: "{{ generate_password | 31 }}"
# ❌ WRONG - too short
PASSWORD: "{{ generate_password | 4 }}"
# ✅ CORRECT
PASSWORD: "{{ generate_password | 32 }}"Generates a URL-friendly slug in the format {adjective}-{noun}-{number}.
x-zane-env:
DB_NAME: "{{ generate_slug }}"
BUCKET_NAME: "{{ generate_slug }}"Output example: happy-tree-91, brave-river-42, quick-mountain-17
Generates a unique domain for your stack in the format:
{project_slug}-{stack_slug}-{random}.{ROOT_DOMAIN}
x-zane-env:
APP_URL: "{{ generate_domain }}"
CALLBACK_URL: "https://{{ generate_domain }}/auth/callback"Output example:
- If project is
my-app, stack isbackend, andROOT_DOMAINiszaneops.dev:my-app-backend-a1b2c3.zaneops.dev
Note: The random suffix ensures uniqueness across environments and prevents collisions.
Generates a UUID v4 (universally unique identifier).
x-zane-env:
LICENSE_ID: "{{ generate_uuid }}"
INSTALLATION_ID: "{{ generate_uuid }}"Output example: 550e8400-e29b-41d4-a716-446655440000
Generates a fake but valid-looking email address.
x-zane-env:
ADMIN_EMAIL: "{{ generate_email }}"
SUPPORT_EMAIL: "{{ generate_email }}"Output example: john.doe@example.com, admin@domain.local
Generates an environment-scoped network alias for inter-service communication within the same environment.
Format: {network_alias_prefix}-{service_name}
Use case: Services communicating within the same environment (e.g., all services in "production" or all services in "staging").
x-zane-env:
DB_HOST: "{{ network_alias | 'postgres' }}"
REDIS_URL: "redis://{{ network_alias | 'redis' }}:6379"Output example:
- If
network_alias_prefixismy-stack:my-stack-postgresmy-stack-redis
Why use this?
- Stable across deployments (doesn't change when stack is redeployed)
- Scoped to the environment - services in the same environment can communicate
- Preferred for most service-to-service communication
Generates a globally unique network alias that is accessible across all of ZaneOps - across all projects and environments.
Format: {hash_prefix}_{service_name}
Use case: Cross-project or cross-environment communication.
x-zane-env:
GLOBAL_DB: "{{ global_alias | 'postgres' }}"Output example:
- If stack hash is
abc123:abc123_postgres
When to use:
- Cross-project service references
- Cross-environment communication (e.g., staging service connecting to production database)
- Debugging and troubleshooting
- Most cases should use
network_aliasinstead for environment isolation
All variables defined in x-zane-env can be referenced using ${VAR} syntax:
x-zane-env:
DB_USER: "{{ generate_username }}"
DB_PASSWORD: "{{ generate_password | 32 }}"
DB_NAME: "{{ generate_slug }}"
DB_HOST: "{{ network_alias | 'postgres' }}"
DB_PORT: "5432"
# Compose variables from other variables
DATABASE_URL: "postgresql://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}"
services:
app:
image: myapp:latest
environment:
# Reference the composed variable
DATABASE_URL: ${DATABASE_URL}Interpolation rules:
- Works in
x-zane-envvalues - Works in service
environmentsections - Works in inline config
content - Evaluated during deployment
- Uses Python's
expandvars.expand()for${VAR}expansion
Important: Template expressions are evaluated once during the first deployment. Generated values are saved as ComposeStackEnvOverride records and reused in subsequent deployments.
Lifecycle:
-
First deployment:
- Template expressions evaluated
- Values generated (e.g., passwords, UUIDs)
- Saved as
ComposeStackEnvOverriderecords
-
Subsequent deployments:
- Existing override values reused
- No regeneration (passwords stay the same)
-
Manual override:
- Use the API to update env override values
- Template expressions won't regenerate once overridden
Example:
x-zane-env:
DB_PASSWORD: "{{ generate_password | 32 }}"- Deploy 1: Generates
a1b2c3d4... - Deploy 2: Reuses
a1b2c3d4... - Deploy 3: Reuses
a1b2c3d4...
services:
app:
image: node:20-alpine
command: ["npm", "start"]
working_dir: /app
user: "1000:1000"
environment:
NODE_ENV: productionimage: Docker image to use (required)
services:
app:
image: nginx:1.25-alpinecommand: Override default command
services:
app:
image: node:20
command: ["node", "server.js"]working_dir: Set working directory
services:
app:
image: python:3.12
working_dir: /appuser: Run as specific user
services:
app:
image: node:20
user: "1000:1000"-
environment: Environment variables (see Environment Variables) -
volumes: Volume mounts (see Volumes) -
configs: Config file mounts (see Docker Configs) -
depends_on: Service dependencies (see Service Dependencies) -
deploy: Deployment configuration (labels, replicas, resources)
The deploy section configures Docker Swarm deployment behavior and ZaneOps routing.
services:
app:
image: myapp:latest
deploy:
replicas: 3
labels:
# ZaneOps routing labels (see Routing section)
zane.http.routes.0.domain: "example.com"
resources:
limits:
cpus: '2'
memory: 1G
reservations:
cpus: '1'
memory: 512M
restart_policy:
condition: on-failure
max_attempts: 3Important properties:
replicas: Number of service replicas (default: 1)- Set to
0to pause the service (status becomesSLEEPING)
- Set to
labels: Routing configuration (ZaneOps-specific, see Routing)resources: CPU and memory limitsrestart_policy: How Docker Swarm handles failures
These standard Docker Compose properties are removed/ignored by ZaneOps:
ports: ZaneOps uses label-based routing, not port mappingsexpose: Not needed for Docker Swarm servicesrestart: Usedeploy.restart_policyinsteadbuild: ZaneOps uses pre-built images only
# ❌ These will be ignored/removed
services:
app:
image: myapp:latest
ports:
- "3000:3000" # Removed - use deploy.labels for routing
expose:
- "3000" # Removed - not needed
restart: always # Removed - use deploy.restart_policyZaneOps uses label-based routing instead of port mappings. Configure routes using deploy.labels.
services:
web:
image: nginx:alpine
deploy:
labels:
zane.http.routes.0.domain: "example.com"
zane.http.routes.0.port: "80"
zane.http.routes.0.base_path: "/"
zane.http.routes.0.strip_prefix: "false"Route index: The number in routes.0 is the route index. Start at 0 for the first route.
zane.http.routes.{N}.domain: Domain name for this routezane.http.routes.{N}.port: Container port to route to
zane.http.routes.{N}.base_path: Path prefix (default:/)zane.http.routes.{N}.strip_prefix: Whether to strip base_path before proxying (default:true). Setting it totruemeans if yourbase_pathis/apiand you receive a request to/api/auth/login, your service will receive a request to/auth/login.
You can configure multiple routes for a single service using different indices:
services:
web:
image: myapp:latest
deploy:
labels:
# Route 0: Main domain
zane.http.routes.0.domain: "example.com"
zane.http.routes.0.port: "8080"
zane.http.routes.0.base_path: "/"
zane.http.routes.0.strip_prefix: "false"
# Route 1: Alternative domain
zane.http.routes.1.domain: "www.example.com"
zane.http.routes.1.port: "8080"
zane.http.routes.1.base_path: "/"
zane.http.routes.1.strip_prefix: "false"
# Route 2: API subdomain
zane.http.routes.2.domain: "api.example.com"
zane.http.routes.2.port: "3000"
zane.http.routes.2.base_path: "/"
zane.http.routes.2.strip_prefix: "false"Rules:
- Indices must be sequential:
0, 1, 2, 3, ... - Each route can have a different port
- Each route can have different path settings
Route different paths to different ports or services:
services:
web:
image: nginx:alpine
deploy:
labels:
# Root path
zane.http.routes.0.domain: "example.com"
zane.http.routes.0.port: "80"
zane.http.routes.0.base_path: "/"
zane.http.routes.0.strip_prefix: "false"
# API path
zane.http.routes.1.domain: "example.com"
zane.http.routes.1.port: "3000"
zane.http.routes.1.base_path: "/api"
zane.http.routes.1.strip_prefix: "true"How it works:
- Request to
example.com/→ port 80 - Request to
example.com/api/users→ port 3000 (receives/usersif strip_prefix=true)
x-zane-env:
APP_DOMAIN: "{{ generate_domain }}"
services:
web:
image: myapp:latest
deploy:
labels:
zane.http.routes.0.domain: "${APP_DOMAIN}"
zane.http.routes.0.port: "8080"
zane.http.routes.0.base_path: "/"
zane.http.routes.0.strip_prefix: "false"Result: Domain is auto-generated and stable across deployments.
ZaneOps validates routes during deployment:
✅ Valid:
zane.http.routes.0.domain: "example.com"
zane.http.routes.0.port: "80"❌ Invalid:
# Missing domain
zane.http.routes.0.port: "80"
# Missing port
zane.http.routes.0.domain: "example.com"
# Invalid port
zane.http.routes.0.domain: "example.com"
zane.http.routes.0.port: "not-a-number"
# Invalid strip_prefix
zane.http.routes.0.domain: "example.com"
zane.http.routes.0.port: "80"
zane.http.routes.0.strip_prefix: "yes" # Must be "true" or "false"ZaneOps supports named volumes, absolute path bind mounts, and external volumes. Relative path bind mounts are not supported and will be rejected during validation.
Named volumes are managed by Docker and persist across deployments.
services:
db:
image: postgres:16
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata:Properties:
- Persists data across deployments
- Managed by Docker
- Can be backed up using Docker commands
With driver options:
services:
db:
image: postgres:16
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata:
driver: local
driver_opts:
type: none
o: bind
device: /mnt/data/postgresBind mounts with absolute paths are supported for mapping host directories into containers.
services:
web:
image: nginx:alpine
volumes:
- /data/html:/usr/share/nginx/html:ro
- /etc/myapp/nginx.conf:/etc/nginx/nginx.conf:roProperties:
- Must use absolute paths (starting with
/) - Useful for accessing host system files or shared storage
- Use
:rosuffix for read-only mounts
Important: ZaneOps does not support relative path bind mounts and will actively validate against them.
# ❌ NOT SUPPORTED - will fail validation
services:
web:
image: nginx:alpine
volumes:
- ./html:/usr/share/nginx/html:ro
- ./nginx.conf:/etc/nginx/nginx.conf:ro
- ../data:/app/dataUse inline configs instead for configuration files:
# ✅ Correct approach for config files
services:
web:
image: nginx:alpine
configs:
- source: nginx_config
target: /etc/nginx/nginx.conf
configs:
nginx_config:
content: |
server {
listen 80;
}Or use absolute paths:
# ✅ Correct approach for host directories/files
services:
portainer:
image: portainer/portainer-ce:latest
volumes:
- /var/run/docker.sock:/var/run/docker.sock:roNote: The ../files/ path pattern is only handled by the Dokploy Migration Adapter when converting Dokploy templates to ZaneOps format. It is not valid syntax for native ZaneOps templates.
Reference volumes created outside the stack:
services:
app:
image: myapp:latest
volumes:
- shared_data:/data
volumes:
shared_data:
external: true
name: actual_volume_nameUse case: Share volumes between stacks.
services:
app:
volumes:
# Named volume
- volume_name:/container/path
# Named volume (read-only)
- volume_name:/container/path:ro
# Absolute path bind mount
- /host/path:/container/path
# Absolute path bind mount (read-only)
- /host/path:/container/path:ro
# Long syntax
- type: volume
source: volume_name
target: /container/path
read_only: falseNote: Relative path bind mounts (./path:/container/path) are not supported. Use absolute paths, named volumes, or inline configs instead.
Docker configs allow you to inject configuration files into containers without rebuilding images. ZaneOps supports inline configs with automatic versioning.
Define config file content directly in the compose file:
services:
web:
image: nginx:alpine
configs:
- source: nginx_config
target: /etc/nginx/nginx.conf
configs:
nginx_config:
content: |
user nginx;
worker_processes auto;
events {
worker_connections 1024;
}
http {
server {
listen 80;
location / {
root /usr/share/nginx/html;
}
}
}How it works:
- ZaneOps extracts
contentfrom config definition - Creates versioned config name:
nginx_config_v1 - If content changes in next deployment:
nginx_config_v2 - Old config versions cleaned up automatically
Configs support ${VAR} interpolation from x-zane-env:
x-zane-env:
DB_HOST: "{{ network_alias | 'postgres' }}"
DB_PORT: "5432"
DB_NAME: "{{ generate_slug }}"
services:
app:
image: myapp:latest
configs:
- source: app_config
target: /app/config.json
configs:
app_config:
content: |
{
"database": {
"host": "${DB_HOST}",
"port": ${DB_PORT},
"name": "${DB_NAME}"
}
}Result: Variables expanded before creating Docker config.
services:
web:
image: nginx:alpine
configs:
- source: nginx_conf
target: /etc/nginx/nginx.conf
- source: site_conf
target: /etc/nginx/conf.d/default.conf
- source: ssl_cert
target: /etc/ssl/certs/cert.pem
configs:
nginx_conf:
content: |
user nginx;
worker_processes auto;
site_conf:
content: |
server {
listen 80;
server_name example.com;
}
ssl_cert:
content: |
-----BEGIN CERTIFICATE-----
...
-----END CERTIFICATE-----How versioning works:
-
First deployment with inline config:
- Config name:
nginx_config_v1 - Stored in
ComposeStack.configsas:{ "nginx_config": { "content": "...", "version": 1 } }
- Config name:
-
Second deployment with same content:
- Config name:
nginx_config_v1(reused) - Version stays at
1
- Config name:
-
Third deployment with different content:
- Config name:
nginx_config_v2(new version) - Version increments to
2 - Old
nginx_config_v1marked for cleanup
- Config name:
Why versioning?
- Docker configs are immutable (can't update existing config)
- Versioning allows updates without manual config management
- Old versions cleaned up automatically
ZaneOps automatically manages networking for your services. Understanding the network architecture helps you configure service communication correctly.
All services automatically get:
-
zanenetwork (global overlay network)- Connects all ZaneOps services across all stacks
- Used for ZaneOps internal communication (proxy, monitoring)
-
Environment network (e.g.,
zn-env-abc123)- Scoped to your environment (production, staging, etc.)
- Used for services in the same environment to communicate
-
Stack default network (e.g.,
zn-compose_stk_xyz789_default)- Scoped to your stack
- Used for inter-service communication within the stack
To prevent DNS collisions, ZaneOps hashes all service names:
Original compose:
services:
app:
image: myapp:latest
postgres:
image: postgres:16After processing:
- Service names become:
abc123_app,abc123_postgres - Where
abc123is the stack's unique hash prefix
DNS Resolution:
In the zane network:
abc123_app.zaneops.internalabc123_postgres.zaneops.internal
In the environment network:
{network_alias_prefix}-app(e.g.,my-stack-app){network_alias_prefix}-postgres(e.g.,my-stack-postgres)
In the stack default network:
app(original name, for convenience)postgres(original name, for convenience)
Within the same stack (preferred):
x-zane-env:
DB_HOST: "{{ network_alias | 'postgres' }}"
services:
app:
image: myapp:latest
environment:
DATABASE_HOST: ${DB_HOST}
postgres:
image: postgres:16Result: App connects to my-stack-postgres (environment network alias).
Using default network (also works):
services:
app:
image: myapp:latest
environment:
# Reference by original service name
DATABASE_HOST: postgres
postgres:
image: postgres:16Result: App connects to postgres (resolves to abc123_postgres in stack default network).
Cross-stack communication (advanced):
x-zane-env:
SHARED_DB: "{{ global_alias | 'postgres' }}"
services:
app:
image: myapp:latest
environment:
# Connect to postgres from another stack
DATABASE_HOST: ${SHARED_DB}Result: App connects to xyz789_postgres (global alias from another stack's postgres).
You can define custom networks, but it's rarely needed:
services:
frontend:
image: frontend:latest
networks:
- frontend
- backend
backend:
image: backend:latest
networks:
- backend
db:
image: postgres:16
networks:
- backend
networks:
frontend:
backend:Notes:
- Custom networks are in addition to the automatic
zane, environment, and default networks - Use custom networks for advanced isolation scenarios
- Most stacks don't need custom networks
services:
app:
image: myapp:latest
environment:
NODE_ENV: production
PORT: "3000"
API_KEY: "secret"Alternative syntax (list format):
services:
app:
image: myapp:latest
environment:
- NODE_ENV=production
- PORT=3000Reference variables defined in x-zane-env using ${VAR} syntax:
x-zane-env:
DB_USER: "{{ generate_username }}"
DB_PASSWORD: "{{ generate_password | 32 }}"
DB_HOST: "{{ network_alias | 'postgres' }}"
services:
app:
image: myapp:latest
environment:
DATABASE_USER: ${DB_USER}
DATABASE_PASSWORD: ${DB_PASSWORD}
DATABASE_HOST: ${DB_HOST}Compose complex values from multiple variables:
x-zane-env:
DB_USER: "{{ generate_username }}"
DB_PASSWORD: "{{ generate_password | 32 }}"
DB_NAME: "{{ generate_slug }}"
DB_HOST: "{{ network_alias | 'postgres' }}"
DB_PORT: "5432"
# Compose connection string
DATABASE_URL: "postgresql://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}"
services:
app:
image: myapp:latest
environment:
DATABASE_URL: ${DATABASE_URL}Use depends_on to control service startup order:
services:
app:
image: myapp:latest
depends_on:
- postgres
- redis
postgres:
image: postgres:16
redis:
image: redis:alpineImportant notes:
- ZaneOps converts
depends_ondict format to list format - Docker Swarm only uses this for initial startup order
- Does not wait for service to be "ready" (just started)
- Does not affect deployment order (all services deployed in parallel)
Dict format (Docker Compose v3.8+):
services:
app:
depends_on:
postgres:
condition: service_healthyConverted to list (Docker Swarm compatible):
services:
app:
depends_on:
- postgresservices:
web:
image: nginx:alpine
deploy:
replicas: 2
labels:
zane.http.routes.0.domain: "myapp.com"
zane.http.routes.0.port: "80"
zane.http.routes.0.base_path: "/"
zane.http.routes.0.strip_prefix: "false"x-zane-env:
# Database credentials
DB_USER: "{{ generate_username }}"
DB_PASSWORD: "{{ generate_password | 32 }}"
DB_NAME: "{{ generate_slug }}"
DB_HOST: "{{ network_alias | 'postgres' }}"
# Connection string
DATABASE_URL: "postgresql://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:5432/${DB_NAME}"
# API configuration
API_SECRET: "{{ generate_password | 64 }}"
API_DOMAIN: "{{ generate_domain }}"
services:
frontend:
image: myapp/frontend:latest
environment:
API_URL: "https://${API_DOMAIN}"
deploy:
replicas: 2
labels:
zane.http.routes.0.domain: "myapp.com"
zane.http.routes.0.port: "3000"
zane.http.routes.0.base_path: "/"
zane.http.routes.0.strip_prefix: "false"
backend:
image: myapp/backend:latest
environment:
DATABASE_URL: ${DATABASE_URL}
SECRET_KEY: ${API_SECRET}
PORT: "8080"
depends_on:
- postgres
deploy:
replicas: 3
labels:
zane.http.routes.0.domain: "${API_DOMAIN}"
zane.http.routes.0.port: "8080"
zane.http.routes.0.base_path: "/"
zane.http.routes.0.strip_prefix: "false"
postgres:
image: postgres:16
environment:
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: ${DB_NAME}
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata:x-zane-env:
APP_NAME: "{{ generate_slug }}"
DB_HOST: "{{ network_alias | 'postgres' }}"
services:
web:
image: nginx:alpine
configs:
- source: nginx_config
target: /etc/nginx/nginx.conf
- source: app_config
target: /etc/app/config.json
deploy:
labels:
zane.http.routes.0.domain: "example.com"
zane.http.routes.0.port: "80"
configs:
nginx_config:
content: |
user nginx;
worker_processes auto;
events {
worker_connections 1024;
}
http {
server {
listen 80;
server_name example.com;
location / {
root /usr/share/nginx/html;
index index.html;
}
}
}
app_config:
content: |
{
"app_name": "${APP_NAME}",
"database": {
"host": "${DB_HOST}",
"port": 5432
}
}x-zane-env:
REDIS_HOST: "{{ network_alias | 'redis' }}"
DB_HOST: "{{ network_alias | 'postgres' }}"
DB_PASSWORD: "{{ generate_password | 32 }}"
services:
web:
image: myapp/web:latest
environment:
REDIS_URL: "redis://${REDIS_HOST}:6379"
deploy:
replicas: 2
labels:
# Main site
zane.http.routes.0.domain: "example.com"
zane.http.routes.0.port: "3000"
zane.http.routes.0.base_path: "/"
# www subdomain
zane.http.routes.1.domain: "www.example.com"
zane.http.routes.1.port: "3000"
zane.http.routes.1.base_path: "/"
depends_on:
- redis
api:
image: myapp/api:latest
environment:
DATABASE_HOST: ${DB_HOST}
DATABASE_PASSWORD: ${DB_PASSWORD}
deploy:
replicas: 3
labels:
# API subdomain
zane.http.routes.0.domain: "api.example.com"
zane.http.routes.0.port: "8080"
zane.http.routes.0.base_path: "/"
depends_on:
- postgres
postgres:
image: postgres:16
environment:
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- pgdata:/var/lib/postgresql/data
redis:
image: redis:alpine
volumes:
- redis_data:/data
volumes:
pgdata:
redis_data:x-zane-env:
MYSQL_ROOT_PASSWORD: "{{ generate_password | 32 }}"
MYSQL_USER: "{{ generate_username }}"
MYSQL_PASSWORD: "{{ generate_password | 32 }}"
MYSQL_DATABASE: "{{ generate_slug }}"
MYSQL_HOST: "{{ network_alias | 'mysql' }}"
WP_DOMAIN: "{{ generate_domain }}"
services:
wordpress:
image: wordpress:6-apache
environment:
WORDPRESS_DB_HOST: ${MYSQL_HOST}
WORDPRESS_DB_USER: ${MYSQL_USER}
WORDPRESS_DB_PASSWORD: ${MYSQL_PASSWORD}
WORDPRESS_DB_NAME: ${MYSQL_DATABASE}
volumes:
- wp_content:/var/www/html/wp-content
depends_on:
- mysql
deploy:
labels:
zane.http.routes.0.domain: "${WP_DOMAIN}"
zane.http.routes.0.port: "80"
zane.http.routes.0.base_path: "/"
zane.http.routes.0.strip_prefix: "false"
mysql:
image: mysql:8
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
MYSQL_USER: ${MYSQL_USER}
MYSQL_PASSWORD: ${MYSQL_PASSWORD}
MYSQL_DATABASE: ${MYSQL_DATABASE}
volumes:
- mysql_data:/var/lib/mysql
volumes:
wp_content:
mysql_data:ZaneOps includes an adapter to import templates from Dokploy. If you have existing Dokploy templates, here's how to migrate them.
Dokploy templates are base64-encoded JSON containing:
- compose: Docker Compose YAML with placeholders
- config: TOML with variables, domains, env, and file mounts
Example Dokploy template structure (decoded):
{
"compose": "services:\n web:\n image: nginx\n environment:\n PASSWORD: ${password}\n",
"config": "[variables]\npassword = \"${password:32}\"\n\n[[config.domains]]\nserviceName = \"web\"\nhost = \"example.com\"\nport = 80\n"
}Dokploy placeholders are automatically converted to ZaneOps template expressions:
| Dokploy Placeholder | ZaneOps Expression |
|---|---|
${domain} |
{{ generate_domain }} |
${email} |
{{ generate_email }} |
${username} |
{{ generate_username }} |
${uuid} |
{{ generate_uuid }} |
${password} |
{{ generate_password | 32 }} |
${password:16} |
{{ generate_password | 16 }} |
${base64} |
{{ generate_password | 32 }} |
${base64:64} |
{{ generate_password | 64 }} |
${hash} |
{{ generate_password | 32 }} |
${hash:16} |
{{ generate_password | 16 }} |
${jwt} |
{{ generate_password | 32 }} |
${jwt:64} |
{{ generate_password | 64 }} |
The DokployComposeAdapter.to_zaneops(base64_template) method:
- Decode and parse: Base64 → JSON → {compose, config}
- Convert placeholders: Replace Dokploy placeholders with ZaneOps template expressions
- Process variables: Extract
[variables]section →x-zane-env - Process domains: Extract
[[config.domains]]→deploy.labels - Process mounts: Convert
[[config.mounts]]→ inline configs - Clean up: Remove
ports,expose,restart - Output: ZaneOps-compatible compose YAML
Dokploy compose.yaml:
services:
web:
image: nginx:alpine
ports:
- "8080:80"
environment:
DB_PASSWORD: ${DB_PASSWORD}
ADMIN_EMAIL: ${ADMIN_EMAIL}
volumes:
- ../files/nginx.conf:/etc/nginx/nginx.confDokploy config.toml:
[variables]
main_domain = "${domain}"
db_password = "${password:32}"
admin_email = "${email}"
[[config.domains]]
serviceName = "web"
host = "${main_domain}"
port = 8080
[[config.env]]
DB_PASSWORD = "${db_password}"
ADMIN_EMAIL = "${admin_email}"
[[config.mounts]]
filePath = "nginx.conf"
content = """
server {
listen 80;
}
"""Resulting ZaneOps compose.yaml:
x-zane-env:
main_domain: "{{ generate_domain }}"
db_password: "{{ generate_password | 32 }}"
admin_email: "{{ generate_email }}"
DB_PASSWORD: ${db_password}
ADMIN_EMAIL: ${admin_email}
services:
web:
image: nginx:alpine
environment:
DB_PASSWORD: ${DB_PASSWORD}
ADMIN_EMAIL: ${ADMIN_EMAIL}
configs:
- source: nginx.conf
target: /etc/nginx/nginx.conf
deploy:
labels:
zane.http.routes.0.domain: "${main_domain}"
zane.http.routes.0.port: "8080"
zane.http.routes.0.base_path: "/"
zane.http.routes.0.strip_prefix: "false"
configs:
nginx.conf:
content: |
server {
listen 80;
}Dokploy uses ../files/ prefix for file mounts. The adapter converts these to Docker configs.
Case 1: Directory mount
Dokploy:
volumes:
- ../files/clickhouse_config:/etc/clickhouse-server/config.d
[[config.mounts]]
filePath = "clickhouse_config/logging_rules.xml"
content = "..."
[[config.mounts]]
filePath = "clickhouse_config/network.xml"
content = "..."ZaneOps result:
configs:
- source: logging_rules.xml
target: /etc/clickhouse-server/config.d/logging_rules.xml
- source: network.xml
target: /etc/clickhouse-server/config.d/network.xml
configs:
logging_rules.xml:
content: "..."
network.xml:
content: "..."Case 2: File mount
Dokploy:
volumes:
- ../files/nginx.conf:/etc/nginx/nginx.conf:ro
[[config.mounts]]
filePath = "nginx.conf"
content = "..."ZaneOps result:
configs:
- source: nginx.conf
target: /etc/nginx/nginx.conf
configs:
nginx.conf:
content: "..."Case 3: Non-existent path (becomes volume)
Dokploy:
volumes:
- ../files/data:/app/dataIf no matching mount exists → converted to named volume:
volumes:
- data:/app/data
volumes:
data:x-zane-env:
REDIS_HOST: "{{ network_alias | 'redis' }}"
REDIS_PORT: "6379"
REDIS_URL: "redis://${REDIS_HOST}:${REDIS_PORT}"
DB_HOST: "{{ network_alias | 'postgres' }}"
DB_USER: "{{ generate_username }}"
DB_PASSWORD: "{{ generate_password | 32 }}"
DB_NAME: "{{ generate_slug }}"
DB_URL: "postgresql://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:5432/${DB_NAME}"
services:
web:
image: myapp/web:latest
environment:
REDIS_URL: ${REDIS_URL}
DATABASE_URL: ${DB_URL}
worker:
image: myapp/worker:latest
environment:
REDIS_URL: ${REDIS_URL}
DATABASE_URL: ${DB_URL}
scheduler:
image: myapp/scheduler:latest
environment:
REDIS_URL: ${REDIS_URL}
DATABASE_URL: ${DB_URL}
redis:
image: redis:alpine
postgres:
image: postgres:16
environment:
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: ${DB_NAME}Benefit: Single source of truth for connection strings.
x-zane-env:
FEATURE_NEW_UI: "true"
FEATURE_BETA_API: "false"
FEATURE_ANALYTICS: "true"
services:
app:
image: myapp:latest
environment:
FEATURE_NEW_UI: ${FEATURE_NEW_UI}
FEATURE_BETA_API: ${FEATURE_BETA_API}
FEATURE_ANALYTICS: ${FEATURE_ANALYTICS}Benefit: Toggle features by updating env overrides via API (no redeployment needed if app hot-reloads).
services:
worker:
image: myapp/worker:latest
deploy:
replicas: 0 # Set to 0 to pause, > 0 to resumeBenefit: Pause services (e.g., background workers) without deleting the stack. Service status becomes SLEEPING.
Use different configs per environment:
x-zane-env:
ENV_NAME: "production" # Override via API for staging/dev
LOG_LEVEL: "info" # Override to "debug" for dev
services:
app:
image: myapp:latest
environment:
ENVIRONMENT: ${ENV_NAME}
LOG_LEVEL: ${LOG_LEVEL}Benefit: Same template, different behavior per environment via overrides.
services:
postgres:
image: postgres:16
configs:
- source: init_sql
target: /docker-entrypoint-initdb.d/init.sql
configs:
init_sql:
content: |
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
email TEXT UNIQUE NOT NULL
);Benefit: Initialize database schema on first startup.
Symptom: Variable shows {{ generate_password | 32 }} literally instead of generated value.
Cause: Variable not defined in x-zane-env, or wrong syntax.
Solution:
# ❌ Wrong - not in x-zane-env
services:
app:
environment:
PASSWORD: "{{ generate_password | 32 }}"
# ✅ Correct
x-zane-env:
PASSWORD: "{{ generate_password | 32 }}"
services:
app:
environment:
PASSWORD: ${PASSWORD}Symptom: Connection refused or DNS resolution failure.
Possible causes:
- Wrong hostname: Using hashed name instead of alias
- Service not started:
depends_ondoesn't wait for readiness - Network isolation: Custom network without
zanenetwork
Solution 1: Use network_alias template function
x-zane-env:
DB_HOST: "{{ network_alias | 'postgres' }}"
services:
app:
environment:
DATABASE_HOST: ${DB_HOST}Solution 2: Use original service name (works in stack default network)
services:
app:
environment:
DATABASE_HOST: postgres # Resolves to hashed name automaticallySolution 3: Add health check and retry logic in app
// App code
async function connectWithRetry() {
const maxRetries = 10;
for (let i = 0; i < maxRetries; i++) {
try {
await db.connect();
return;
} catch (err) {
await sleep(5000);
}
}
throw new Error('Failed to connect');
}Symptom: Deployment fails with route validation error.
Common mistakes:
# ❌ Missing port
deploy:
labels:
zane.http.routes.0.domain: "example.com"
# ❌ Missing domain
deploy:
labels:
zane.http.routes.0.port: "80"
# ❌ Invalid port
deploy:
labels:
zane.http.routes.0.domain: "example.com"
zane.http.routes.0.port: "not-a-number"
# ❌ Invalid strip_prefix
deploy:
labels:
zane.http.routes.0.domain: "example.com"
zane.http.routes.0.port: "80"
zane.http.routes.0.strip_prefix: "yes" # Must be "true" or "false"Solution: Ensure both domain and port are present and valid
# ✅ Correct
deploy:
labels:
zane.http.routes.0.domain: "example.com"
zane.http.routes.0.port: "80"
zane.http.routes.0.base_path: "/"
zane.http.routes.0.strip_prefix: "false"Symptom: Changes to inline config content not reflected in container.
Cause: Config versioning - old config still referenced.
Solution:
- Check deployed config version:
docker config ls | grep nginx_config - Verify content changed (triggers version increment)
- Redeploy stack (new version created automatically)
Note: If content is identical, version won't increment (working as intended).
Symptom: Data in volume disappears after redeployment.
Cause: Volume not properly defined in the volumes section.
Solution: Always define named volumes in the top-level volumes section
# ❌ Wrong - volume not defined
services:
db:
volumes:
- pgdata:/var/lib/postgresql/data
# ✅ Correct - named volume properly defined
services:
db:
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata:Symptom: Deployment fails with "Password length must be even and >= 8".
Cause: Invalid length parameter.
Solution:
# ❌ Wrong - odd length
x-zane-env:
PASSWORD: "{{ generate_password | 31 }}"
# ❌ Wrong - too short
x-zane-env:
PASSWORD: "{{ generate_password | 4 }}"
# ✅ Correct
x-zane-env:
PASSWORD: "{{ generate_password | 32 }}"Valid lengths: 8, 10, 12, 14, 16, 18, 20, ... (any even number 8 or greater)
Symptom: Variable shows ${VAR} literally instead of expanded value.
Cause: Variable not defined in x-zane-env, or wrong syntax.
Solution:
# ❌ Wrong - VAR not defined
services:
app:
environment:
DATABASE_URL: "postgresql://user:pass@${DB_HOST}:5432/db"
# ✅ Correct - define in x-zane-env first
x-zane-env:
DB_HOST: "{{ network_alias | 'postgres' }}"
services:
app:
environment:
DATABASE_URL: "postgresql://user:pass@${DB_HOST}:5432/db"Symptom: Deployment status shows FAILED.
Common causes:
- Invalid image: Image doesn't exist or wrong tag
- Resource limits: Not enough CPU/memory
- Invalid config: Syntax error in inline config
- Port conflict: Multiple routes to same port with conflicting paths
Debug steps:
- Check deployment logs in ZaneOps UI
- Check Docker service logs:
docker service ps <service_id> --no-trunc docker service logs <service_id>
- Verify image exists:
docker pull <image>:<tag>
- Check resource availability:
docker node ls docker node inspect <node_id>
Symptom: Domain resolves but returns 404.
Possible causes:
- Service not healthy: Container running but app not listening
- Wrong port: Route port doesn't match app listen port
- Base path mismatch: App expects path prefix but strip_prefix=true
Debug steps:
- Check service status in ZaneOps UI
- Test service directly (bypass proxy):
docker exec -it <container_id> curl localhost:<port>
- Verify app is listening:
docker exec -it <container_id> netstat -tlnp
- Check Caddy config:
docker exec -it <caddy_container> cat /etc/caddy/Caddyfile
Within the same stack: Use the service name directly (simpler and works via the stack's default network).
# ✅ Recommended for same-stack communication
services:
app:
environment:
DB_HOST: postgres
REDIS_HOST: redisAcross different stacks in the same environment: Use network_alias for stable, environment-scoped DNS.
# ✅ Recommended for cross-stack communication (same environment)
x-zane-env:
DB_HOST: "{{ network_alias | 'postgres' }}"
REDIS_HOST: "{{ network_alias | 'redis' }}"Across different environments or globally in ZaneOps: Use global_alias for globally unique DNS.
# ✅ Recommended for cross-environment communication
x-zane-env:
SHARED_DB: "{{ global_alias | 'postgres' }}"Why: Service names are simplest for intra-stack communication. network_alias provides stable DNS for cross-stack scenarios within the same environment. global_alias is needed when communicating across environments or projects.
# ✅ Recommended
volumes:
pgdata:
redis_data:Why: Survives deployments and container recreation.
- Commit compose files to git
- Use branches for different environments
- Tag deployments in git
# ✅ Good for configs < 100 lines
configs:
nginx_config:
content: |
...Why: Easier to manage, version controlled, automatic versioning.
services:
app:
deploy:
resources:
limits:
cpus: '2'
memory: 1G
reservations:
cpus: '0.5'
memory: 256MWhy: Prevents one service from consuming all resources.
# ❌ Unclear
x-zane-env:
P1: "{{ generate_password | 32 }}"
P2: "{{ generate_password | 32 }}"
# ✅ Clear
x-zane-env:
DB_PASSWORD: "{{ generate_password | 32 }}"
API_SECRET: "{{ generate_password | 64 }}"# Database configuration
# Uses environment-scoped alias for stable DNS across PR previews
x-zane-env:
DB_HOST: "{{ network_alias | 'postgres' }}"
DB_USER: "{{ generate_username }}"
DB_PASSWORD: "{{ generate_password | 32 }}"Before deploying to ZaneOps:
- Validate syntax with
docker compose config - Test locally with
docker compose up - Verify service communication
- Check resource usage
ZaneOps extends Docker Compose with powerful template expressions and automatic orchestration. Key takeaways:
- Use
x-zane-envfor stack-wide variables with template expressions - Template functions generate secrets, domains, and service aliases
- Label-based routing replaces port mappings
- Service name hashing prevents DNS collisions
- Inline configs with automatic versioning
- Named volumes for persistent data
network_aliasfor stable inter-service communication- Lazy computation - templates processed on deployment only
- Value persistence - generated secrets reused across deployments
- Dokploy compatibility - easy migration from existing templates