Migrate from Compose
Turn a docker-compose.yml into a parity.yaml, key by key.
Most docker-compose.yml files map to parity.yaml key by key. The big change: databases, caches, object storage and mail catchers stop being containers you run. They become managed entries, and your services get their URLs as environment variables.
A worked example
A typical Compose file:
# docker-compose.yml
services:
web:
build: .
ports: ["3000:3000"]
environment:
DATABASE_URL: postgres://app:secret@postgres:5432/app
REDIS_URL: redis://redis:6379
SMTP_HOST: mailhog
SMTP_PORT: "1025"
S3_ENDPOINT: http://minio:9000
S3_BUCKET: uploads
SECRET_KEY: change-me
depends_on:
postgres: { condition: service_healthy }
redis: { condition: service_started }
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
worker:
build: .
command: npm run worker
environment:
DATABASE_URL: postgres://app:secret@postgres:5432/app
REDIS_URL: redis://redis:6379
migrate:
build: .
command: npm run migrate
depends_on: [postgres]
postgres:
image: postgres:16
environment: { POSTGRES_USER: app, POSTGRES_PASSWORD: secret, POSTGRES_DB: app }
volumes: [pgdata:/var/lib/postgresql/data]
redis:
image: redis:7
mailhog:
image: mailhog/mailhog
minio:
image: minio/minio
command: server /data
volumes:
pgdata:The same app on Parity:
# parity.yaml
version: 1
project: shop
managed:
postgres: { type: postgres, version: "16" }
redis: { type: redis }
mail: { type: email }
storage: { type: s3, buckets: [uploads] }
env_groups:
common:
SECRET_KEY: { generate: secret }
services:
web:
build: .
ports: { http: 3000 }
health: { http: /health }
expose: true
env_from: [common]
depends_on: [postgres, redis, mail, storage]
env:
S3_ENDPOINT: ${storage.public_endpoint}
S3_BUCKET: ${storage.bucket.uploads}
worker:
build: { from: web }
command: npm run worker
env_from: [common]
depends_on: [postgres, redis]
jobs:
migrate: { service: web, command: npm run migrate, on: deploy }What changed:
- Postgres, Redis, MailHog and MinIO are gone as containers. Each is a
managedentry.depends_on: [postgres]gives the serviceDATABASE_URLand thePG*variables,redisgivesREDIS_URL, andmailgivesSMTP_URL,SMTP_HOSTandSMTP_PORT. No password is written anywhere: each sandbox generates its own. - Keeping the names keeps the hosts. A managed entry's name is its host, so naming it
postgreskeepspostgres:5432working for code that hardcodes it. migrateis a job, not a service.on: deployruns it before services take traffic, and on every update.SECRET_KEYis generated once per sandbox, and shared by every service in thecommongroup.workerreusesweb's build withbuild: { from: web }, so the image is built once.- The health check is an HTTP path, not a command.
- No
volumes. Managed data lives as long as the sandbox.
Run parity check to see every variable each service gets, then parity up.
Field by field
| Compose | Parity |
|---|---|
image | image |
build: . or build: { context, dockerfile, args } | The same, under build. dockerfile is inside context. |
The same build on several services | build: { from: <service> } on all but one |
command | command. A shell string runs under sh -c, a list runs as written. |
entrypoint | Not a field. Change the image, or use a kubernetes.patch. |
ports: ["3000:3000"] | ports: { http: 3000 }, or nothing if the app reads PORT (set to 8080 by default). There are no host ports. |
| A port you open in the browser | expose: true, or expose: [{ host: app, path: /api }] |
environment | env. Replace URLs to bundled databases with depends_on or ${db.url}-style references. |
Values shared by several services (x-env: &env plus <<: *env) | env_groups and env_from. Anchors still work too. |
env_file, .env | Not read. Put non-secret values in env and credentials in the secret store ({ secret: <ref> }). |
depends_on with condition: service_healthy | depends_on: [<service>], which waits for its health check |
healthcheck | health: { http: /path } or { tcp: <port> } |
restart | Not needed. Services always restart. |
user | user: "1000", numbers only. Root isn't allowed. |
deploy.resources | resources: { requests: ..., limits: ... } |
deploy.replicas | One per sandbox. parity scale, or replicas in environments for production. |
volumes (named) | Not supported. Move the data to Postgres, Redis or S3. Scratch space: an emptyDir in a kubernetes.patch. |
volumes (bind mounts of source) | Not needed. parity up uploads and builds your code. |
volumes (bind mounts of config files, such as ./nginx.conf:/etc/nginx/nginx.conf) | Copy the file into the image. For a third-party image, add a two-line Dockerfile (FROM nginx:1.27 and COPY nginx.conf /etc/nginx/nginx.conf) and point the service's build: at it. The file is then versioned with the image and identical in every environment. |
develop.watch | The same rules, for sync, sync+restart and rebuild, without ignore. parity up applies them, with no watcher. develop.command sets the dev command parity up --dev runs. See Develop. |
networks, links, extra_hosts | Not needed. Everything in a sandbox reaches everything else by name. |
profiles | Not supported. List only what a sandbox should run. |
x- extension fields | Work the same way. |
One-off docker compose run web <cmd> | A job without on, run with parity run <job>, or parity exec web -- <cmd> |
Bundled services, by image
| Compose image | Managed entry | Your app gets |
|---|---|---|
postgres, pgvector/pgvector | { type: postgres } | DATABASE_URL, PG* |
redis, valkey, keydb | { type: redis } | REDIS_URL |
minio, localstack (S3 only), seaweedfs | { type: s3, buckets: [...] } | AWS_ENDPOINT_URL_S3, AWS_REGION, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY |
mailhog, mailpit, maildev | { type: email } | SMTP_URL, SMTP_HOST, SMTP_PORT; read mail with parity inbox |
clickhouse/clickhouse-server | { type: clickhouse } | CLICKHOUSE_URL, CLICKHOUSE_USER, CLICKHOUSE_PASSWORD, CLICKHOUSE_DB |
keycloak, dex (for local OIDC) | { type: auth } | OIDC_ISSUER, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET |
mysql, mariadb, mongo, rabbitmq, kafka, elasticsearch | None | Run it as one of your own services with image:, keeping in mind it has no persistent volume, or move to a managed type. |
Variable names that don't match
The standard variables follow common conventions. When your app reads other names, map them with references:
env:
DB_HOST: ${postgres.host}
DB_PASSWORD: ${postgres.password}
CACHE_URL: "${redis.url}/1"
MINIO_ENDPOINT: ${storage.endpoint}
APP_URL: ${web.public_url}Use public_endpoint for storage URLs a browser follows (presigned uploads), and endpoint for server-only calls. ${web.public_url} is the sandbox's public URL for web, for links in emails and OAuth callbacks.
Common snags
- The container runs as root. Sandboxes refuse root. Set
user: "<uid>"to a numeric user that owns the app's files, or addUSER 1000to the Dockerfile. - The app writes to its own directory at runtime (caches, temp files). With a non-root user that directory may not be writable. Give it an
emptyDirwith akubernetes.patch, or point the app at/tmp. - The app calls a third-party API. List its hosts in
externals, or the call is blocked. New hosts work once the promoted manifest lists them. - A migration needs a superuser or an extension. Sandboxes behave like Cloud SQL. Use an available extension, or create it from a
deployjob before the migration. - The app needs a vendor key to boot (Stripe, for example). Sandboxes never get staging or production keys: switch the feature off in sandboxes with a literal
envvalue. - A port isn't 8080. Set
ports: { http: <port> }, or let the app readPORT.