Skip to content
Guides
Markdown

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 managed entry. depends_on: [postgres] gives the service DATABASE_URL and the PG* variables, redis gives REDIS_URL, and mail gives SMTP_URL, SMTP_HOST and SMTP_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 postgres keeps postgres:5432 working for code that hardcodes it.
  • migrate is a job, not a service. on: deploy runs it before services take traffic, and on every update.
  • SECRET_KEY is generated once per sandbox, and shared by every service in the common group.
  • worker reuses web's build with build: { 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

ComposeParity
imageimage
build: . or build: { context, dockerfile, args }The same, under build. dockerfile is inside context.
The same build on several servicesbuild: { from: <service> } on all but one
commandcommand. A shell string runs under sh -c, a list runs as written.
entrypointNot 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 browserexpose: true, or expose: [{ host: app, path: /api }]
environmentenv. 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, .envNot read. Put non-secret values in env and credentials in the secret store ({ secret: <ref> }).
depends_on with condition: service_healthydepends_on: [<service>], which waits for its health check
healthcheckhealth: { http: /path } or { tcp: <port> }
restartNot needed. Services always restart.
useruser: "1000", numbers only. Root isn't allowed.
deploy.resourcesresources: { requests: ..., limits: ... }
deploy.replicasOne 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.watchThe 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_hostsNot needed. Everything in a sandbox reaches everything else by name.
profilesNot supported. List only what a sandbox should run.
x- extension fieldsWork 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 imageManaged entryYour 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, elasticsearchNoneRun 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 add USER 1000 to 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 emptyDir with a kubernetes.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 deploy job 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 env value.
  • A port isn't 8080. Set ports: { http: <port> }, or let the app read PORT.