From docker compose
Deployed and supported. The fields and commands described here are stable within version v1alpha1.
Automatic conversion of a compose file is not yet offered on the command line: this guide gives the mapping to apply by hand. It follows exactly the rules of the platform's converter.
An App manifest reads like a docker-compose.yml: named services, an image,
ports, variables, volumes, dependencies. This guide gives you the field-by-field mapping
to go from one to the other, and lists what does not translate.
Prerequisites
- An open session and a chosen tenant:
sng sso login,sng tenant use. - Your images built and pushed to a registry reachable from the platform
(
build:is not translated). - For a private image: a docker-registry Secret on your tenant (Variables and secrets).
Steps
1. Start from the template
sng template gen App shop
créé /home/vous/shop.yml (kind=App)
The file contains a commented-out web service; replace it with your services.
2. Translate service by service
Take this docker-compose.yml:
name: shop
services:
api:
image: registry.gitlab.com/stackngo/shop-api:1.4.2
command: shop serve --port 8080
ports: ["8080"]
environment:
LOG_LEVEL: info
WORKERS: 4
env_file: [.env]
depends_on:
migrate: { condition: service_completed_successfully }
db: { condition: service_healthy }
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8080/healthz"]
interval: 15s
retries: 5
deploy:
replicas: 2
resources:
reservations: { cpus: "0.25", memory: 256M }
update_config: { order: start-first }
migrate:
image: registry.gitlab.com/stackngo/shop-api:1.4.2
command: ["shop", "migrate"]
depends_on: [db]
db:
image: postgres:17
user: "999"
expose: ["5432"]
environment:
POSTGRES_PASSWORD: changeme
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
volumes:
pgdata:
Its translation:
apiVersion: deploy.stackngo.io/v1alpha1
kind: App
metadata:
name: shop # name of the compose project
spec:
imagePullSecrets: [gitlab-registry] # private registry: Secret to create on the tenant
services:
api:
image: registry.gitlab.com/stackngo/shop-api:1.4.2
command: ["shop", "serve", "--port", "8080"]
ports: ["8080"]
env:
LOG_LEVEL: info
WORKERS: 4
envFrom: [api-env] # env_file → Secret api-env, to create
dependsOn: [db, migrate] # migrate awaited as "completed" → migrate becomes a job
healthcheck:
cmd: ["wget", "-qO-", "http://127.0.0.1:8080/healthz"]
interval: 15s
retries: 5
replicas: 2
size: xs # 0.25 CPU / 256M → smallest size that covers it
strategy: { type: rolling, maxSurge: 1, maxUnavailable: 0 } # order: start-first
ingress: { port: 8080 } # compose has no equivalent: to add
migrate:
kind: job # awaited as service_completed_successfully
image: registry.gitlab.com/stackngo/shop-api:1.4.2
command: ["shop", "migrate"]
dependsOn: [db]
db:
image: postgres:17
user: "999"
ports: ["5432"] # expose → ports (reachable at db:5432 by the other services)
env:
POSTGRES_PASSWORD: { secret: db, key: password } # never the value in clear text
healthcheck: { cmd: ["sh", "-c", "pg_isready -U postgres"] }
volumes:
- { path: /var/lib/postgresql/data, size: 10Gi } # named volume → persistent volume, size to choose
3. Create the expected Secrets
The manifest references gitlab-registry, api-env and db. Create them before or right after
the apply: see Injection into an App. As long as an
environment Secret is missing, the service stays in Progressing with the reason SecretNotFound;
a missing registry Secret blocks the service in Waiting (ImagePullSecretNotFound).
4. Apply and follow
sng apply shop.yml
sng app describe shop
App : shop
Statut : en cours
Message : api : attend : migrate
SERVICE KIND IMAGE PHASE URL DETAIL
api deployment registry.gitlab.com/stackngo/shop-api:1.4.2 Waiting - WaitingForDependency attend : migrate
db deployment postgres:17 Ready - Reconciled 1/1 prêts
migrate job registry.gitlab.com/stackngo/shop-api:1.4.2 Progressing - JobRunning job en cours
Verification
sng app describe shop displays Statut : prête and, for api, the URL
https://api-shop.<tenant>.dev.stackngo.app/. curl -I on this URL responds HTTP/2 200.
Field-by-field mapping
| compose | App | Note |
|---|---|---|
name (project) | metadata.name | lowercase, _ and . become -, 24 characters at most |
| service name | key of spec.services | same normalization; cache_store → cache-store |
image | image | required |
build | — | ignored: build and push the image, then set image |
command | command | list form; a compose string is split as the shell would |
entrypoint | entrypoint | |
user: "999", "1000:1000" | user | only a numeric non-root uid; user: redis is ignored |
environment: K: v | env: K: v | value copied; ${VAR} is not interpolated (replace the value) |
environment: K: (no value) | — | ignored: the value came from your machine |
env_file: [.env] | envFrom: [<service>-env] | the file is not copied: create the Secret <service>-env with its content |
value that looks like a secret (PASSWORD, TOKEN, KEY…) | env: K: { secret: s, key: k } | put it in a Secret |
ports: "8080", "80:8080", "53:53/udp" | ports | published:target; host_ip ignored; a range (8000-8010) is rejected |
expose: ["5432"] | ports: ["5432"] | same thing: a declared port is reachable by the other services of the App |
volumes: name:/path | volumes: [{ path: /path, size: 1Gi }] | persistent volume; size to adjust (1Gi by default); forces a single replica |
volumes: ./file:/path (bind), tmpfs | — | ignored: only named volumes become volumes |
depends_on: [db] | dependsOn: [db] | the service_healthy condition is implicit (the service must be ready) |
depends_on: x: { condition: service_completed_successfully } | dependsOn: [x] and x becomes kind: job | |
healthcheck.test: ["CMD", …] | healthcheck.cmd: […] | |
healthcheck.test: ["CMD-SHELL", "…"] | healthcheck.cmd: ["sh", "-c", "…"] | |
interval, timeout, start_period, retries | interval, timeout, startPeriod, retries | retries bounded to 1–30 |
healthcheck: disable: true or test: ["NONE"] | — | no probe |
pull_policy: always / missing, if_not_present / never | pullPolicy: always / missing / never | |
deploy.replicas: n | replicas: n | 0–100; reduced to 1 if the service has a volume |
deploy.resources.reservations (or limits otherwise) | size | smallest size whose reservations cover CPU and memory; beyond xxxl, xxxl |
deploy.update_config: order: start-first | strategy: { type: rolling, maxSurge: <parallelism or 1>, maxUnavailable: 0 } | no strategy if the service has a volume (it is in recreate) |
deploy.update_config: order: stop-first (or absent) | strategy: { type: rolling, maxSurge: 0, maxUnavailable: <parallelism or 1> } | |
profiles | — | choose the services to keep |
| — | ingress | no compose equivalent: to add on the web service |
| — | egress: [internet] | compose allows outbound Internet; here it is closed by default |
| — | size | default xxs if you specify nothing |
What is not supported
These compose keys have no equivalent and are discarded, most often because they contradict security by default (unprivileged container, closed network):
privileged,cap_add,network_mode,pid,devices,tmpfs;secretsmounted as files and thesecrets:section: useenv: { secret, key }orenvFrom;configsand theconfigs:section;build, bind mounts, port ranges,host_ip;restart,container_name,networks,labels,logging,extra_hosts: not carried over (restart is always automatic, the service name serves as the DNS name, the network is the App's).
Non-root. Your containers run without privileges. An image that starts as root (nginx,
postgres without user) is rejected: use a non-root variant (nginxinc/nginx-unprivileged)
or set user: "<uid>".
Closed network. No outbound Internet without egress: [internet] on the service concerned.
Between services of the same App, only the declared ports are reachable.
Going further
- A compose service named
webin the Appshopis reachable by the other services atweb:<port>, as in compose: what counts is the service name, not the App name. - Sizes and how they are computed from
deploy.resources: Sizes and resources. - The rules that can reject the manifest:
Apppage of the manifest reference.