Compose mapping
How each Docker Compose file, service and key becomes a NebulaCtrl service, database or variable, what blocks an import, and the limits of one import.
The Compose import reads a repository's Compose files as text and proposes NebulaCtrl services, databases, variables and files. This page lists what each part of a Compose file becomes. To run an import, follow Import from Docker Compose.
Files
The import looks in the repository root and in each folder directly below it, or in the build context you set and its folders. A file two folders down is not found.
| Rule | Value |
|---|---|
| File names | compose.yaml, compose.yml, docker-compose.yaml, docker-compose.yml, and variants such as compose.dev.yaml or docker-compose.prod.yml |
| Folders searched | Folders that start with ., node_modules and vendor are skipped, then the first 50 subfolders in alphabetical order are searched. If the repository cannot be listed, only the four standard names in the root are tried, with a note |
| Files listed | 20. Files in the root come first, then each subfolder alphabetically |
| Default selection | The first standard-named file in the searched folder, which is the repository root or the build context you set, in the order compose.yaml, compose.yml, docker-compose.yaml, docker-compose.yml. Without one, the first file listed |
Each file is read on its own and never merged with another, the way docker compose -f a.yaml -f b.yaml would merge them. A .env, env_file or build context in deploy/compose.yaml resolves against deploy/. Every service of every selected file becomes its own service. Two files that both declare web give web and web-2 when the first web is ticked. The name of a ticked service stays unique across the files and against the project.
The override file of a selected file, <name>.override.yaml or <name>.override.yml such as compose.override.yaml beside compose.yaml, is noted and not applied, because it usually holds development settings. Select it as a file of its own, or merge what you need into the base file.
The files are never run. include, and an extends that names another file, are refused because they would read outside the file you picked. An extends within the same file is read. A file that cannot be loaded has the status error, and an import that selects it fails until you untick it. That covers a file with include or a cross-file extends, invalid YAML, and a file over 256 KiB. A .env file and each env_file are read from the same repository at the same commit, at most 64 KiB each, and at most 16 distinct env_file paths per Compose file.
A service name becomes a slug: lowercase, runs of other characters replaced by -, at most 30 characters, and a leading digit prefixed with app-. A name that is already taken, and inputs, get a -2, -3 suffix.
Services
build
Becomes a service built from this repository, on the branch you picked, with the compose build context, Dockerfile and target (the Dockerfile stage).
Build arguments are not imported; a note tells you to put them in the [build.args] table of a nebula.toml beside the Dockerfile. A build that uses an inline Dockerfile, a remote or out-of-repository context, an absolute Dockerfile path, a Dockerfile outside the build context, or a target that does not match ^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$ blocks the service. additional_contexts, ssh and secrets under build produce a warning that the build may fail.
image
Becomes an image service. The reference is normalized to an explicit registry and tag, such as docker.io/library/nginx:1.27. An image without a tag produces a note that asks you to pin a version. A service with neither image nor build, or with an image that is not a valid reference, has nothing to run and blocks. An image beside a build is noted and not used.
Database images
Becomes a database service, where NebulaCtrl runs the engine, when the image is an exact Docker Hub match from this list. Images from other registries never match, and no match is by substring, so postgrest/postgrest and pgadmin stay images.
| Image | Result |
|---|---|
postgres, bitnami/postgresql | PostgreSQL |
mysql, mysql/mysql-server, bitnami/mysql | MySQL |
mongo, mongodb/mongodb-community-server, bitnami/mongodb | MongoDB |
valkey/valkey, bitnami/valkey | Valkey |
redis, bitnami/redis | Valkey, with a warning |
postgis/postgis, pgvector/pgvector, ankane/pgvector, timescale/timescaledb, timescale/timescaledb-ha, supabase/postgres | PostgreSQL offered, but the image is kept by default, because a database service has none of the extensions |
mariadb, linuxserver/mariadb, bitnami/mariadb | MySQL offered, but the image is kept by default |
redis/redis-stack, redis/redis-stack-server | Valkey offered, but the image is kept by default |
bitnamilegacy and bitnamisecure match like bitnami. A Redis tag of 8 or higher is kept as an image. You can switch a kept image to a database service, or a database service back to the image, in the review.
A database service runs the tag's major version when NebulaCtrl runs it, and otherwise the nearest newer major. An image with no tag, or a tag with no readable version such as latest, gets the newest. A tag newer than every major NebulaCtrl runs keeps the image. NebulaCtrl runs PostgreSQL 18 and 16, Valkey 9, MySQL 8 and MongoDB 7. A Redis image can also map to the Valkey 7 template, which still runs the Redis 7 image. Tags such as 16, 16.4, 16-alpine, v7, pg16 and latest-pg16 are read. A Redis image produces the warning that Valkey is compatible with Redis 7.2 and earlier. MariaDB becomes the newest MySQL whatever its tag.
A database service generates its own credentials. The container's password, data volume, command, entrypoint, settings and /docker-entrypoint-initdb.d scripts are dropped, each with a note. Data in a local Compose volume is not migrated: a database service starts empty. A database that is kept as an image runs as user 999 when the service sets no usable user, unless it is a Bitnami, extension or Redis Stack image, or its tag contains alpine.
ports and expose
Becomes one process. Published TCP ports are collected first, then expose ports. The first one collected is the process's port, and a note names the others.
The process is an http process named web when that first port is published on a non-loopback address and is not a port of a well-known non-HTTP protocol (21, 22, 25, 53, 110, 143, 389, 445, 465, 514, 587, 636, 993, 995, 1433, 1521, 1883, 2181, 3306, 3389, 4222, 5432, 5672, 5900, 6379, 6432, 8883, 9042, 9092, 11211, 27017). Otherwise it is a worker named worker, which keeps the port when there is one. A published port range contributes each of its ports, and an expose range contributes only its first port. A non-TCP port is noted and not reachable in the cluster.
command and entrypoint
Becomes the process's arguments and command. Compose entrypoint becomes the process's command, and Compose command becomes its arguments.
user
Becomes the process's user ID when numeric. uid:gid keeps the uid, and root becomes 0. A user name or a group-only value is noted and sets nothing.
deploy, scale, cpus, mem_limit, mem_reservation
Becomes the process's replicas, CPU and memory. Replicas come from scale, else deploy.replicas. CPU and memory limits come from deploy.resources.limits, else cpus and mem_limit. Requests come from deploy.resources.reservations, else mem_reservation, and a request is capped at its limit. A service scaled to zero is proposed but not ticked.
volumes
Becomes a NebulaCtrl volume of 5Gi for each named or anonymous volume, at most 5 per process. An external volume, or one with a driver or driver options, becomes a new empty volume, with a warning. Data in a Compose volume is not migrated. A named volume that several services share needs a decision. A relative bind mount, tmpfs and an absolute host path outside the system paths are imported without effect, with a note.
environment and env_file
Becomes the service's variables, at most 100 per service. A value that contains ${{ is not imported. A variable name that does not match ^[A-Z_][A-Z0-9_]*$ needs a decision. How a value is classified is in Values.
secrets
Becomes a sealed variable and a read-only file. The variable is named after the secret in upper snake case: characters other than A-Z0-9_ become _, a leading digit gets a _ prefix, and a clash gets _2. The file is at the secret's absolute target, or at /run/secrets/<target or name>. The file holds the variable's value, is mounted in every container of the release including the release command, and belongs to the service's files setting, which you can edit later under Release commands and files.
A *_FILE variable keeps its path value and is never secret. A secret that several services use is one variable: the first included service holds it, and every other service reads ${{ SERVICE.KEY }}.
A service blocks when its secret files would sit in a place NebulaCtrl cannot put a file: under /proc, /sys or /dev, directly in /, /etc, /usr, /bin, /sbin, /lib, /var or /tmp, or inside a volume. A service has at most 20 secret files. A cluster whose agent cannot mount files refuses the first deploy and says to update the agent.
depends_on
Becomes the dependent's Deploy after list for every condition except service_completed_successfully. A service_started, service_healthy or short-form entry all mean the dependent deploys after that service is healthy. An entry resolves to the imported service of that name, or to the database service that replaces a Compose database. An entry that names a service left out of the import is dropped with a note. A managed database gets no list, and a cycle is refused. The first deploy starts databases first and then the services along their dependencies. See Deploy order.
service_completed_successfully is not an ordering; see the next entry.
One-shot services
Becomes the release command of the service that waits for it, when the one-shot is not imported as a service of its own.
A service is a one-shot when it has no ports and either its restart is no or on-failure, or another service depends on it with condition: service_completed_successfully. It is folded into every included, non-database service that waits for it with service_completed_successfully, when the one-shot itself is not ticked, and when it runs the same image as the dependent: the same build context, Dockerfile and target, or the same image reference. Its command becomes the release command, and its variables and secrets are merged into the dependent's. It runs before each release of each such service, so a command that runs more than once must be safe. A one-shot that has ports stays a service, and a one-shot that is a database image is not folded.
A one-shot that is not folded is proposed but not ticked. A decision blocks the import when folding would lose something: see Decisions.
profiles
A service behind a Compose profile is proposed and not ticked.
Decisions
A decision is something only you can settle. The import is refused until each decision on a ticked service is accepted. The review shows each decision on its service. Over the API, the ids go in choices.services.<key>.accept. A release- id contains the one-shot's name in the Compose file, and it is accepted on the service that waits for the one-shot.
| Id | Raised when | Accepting means |
|---|---|---|
variable-names | Names do not match ^[A-Z_][A-Z0-9_]*$, such as CLAMD_CONF_StreamMaxLength | The names are left out. Set them another way first, for example a config file in the image |
volume:<name> | Several ticked services mount one named volume | Accept it on one service, and only one. The others get none |
release-image:<one-shot> | The one-shot runs another image | The service is imported without that release command |
release-command:<one-shot> | The one-shot has no command, or one over 8 KiB | Give a release command in choices.services.<key>.releaseCommand to settle it. Accepting without one imports the service with no release command |
release-other:<one-shot> | The one-shot cannot be imported | The service is imported without waiting for it |
release-other:<one-shot>:entrypoint | The one-shot sets an entrypoint that differs from the service's | The command is kept as the release command and runs after the service's own entrypoint |
release-env:<one-shot> | The one-shot sets a variable to another value than the dependent | The dependent keeps its own value |
release-files:<one-shot> | The one-shot's secret files clash with the dependent's | The service is imported without the one-shot's files |
release-more:<one-shot> | A second one-shot waits for the same service | The second one is not folded in |
An image service whose image starts as root, or runs as a named user that Kubernetes cannot verify as non-root, needs a user decision instead. It is not in decisions: the proposal reports it as userDecision, either root or named, and the import is refused until it is answered. Give a user ID in choices.services.<key>.runAsUser, or 0 to allow root. An image that starts as root under an init system (init, tini, dumb-init, s6-svscan, s6-overlay-suexec) is reported with imageInit: true, and the review offers only root for it, because forcing a user ID breaks its setup. A user: in the Compose file already answers it: a numeric ID or root is applied, and any other name is reported in a note and not applied. NebulaCtrl reads at most 20 images, and an image it cannot read is checked again when the import is staged.
What cannot run
A service that uses one of these is blocked. It is listed with the reason and cannot be ticked, because NebulaCtrl drops every capability and runs pods on the cluster network.
privileged,cap_add,devices,device_cgroup_rules,gpus,use_api_socket,provider.network_modeofhost,service:orcontainer:, andpid,ipc,uts,userns_modeorcgroupofhost,service:orcontainer:.- A
security_optthat containsunconfinedordisable. - A host path under a system path (
/,/proc,/sys,/dev,/etc,/boot,/root,/run,/var/run,/var/lib/docker,/var/lib/kubelet,/var/lib/containerd,/var/log,/usr,/lib,/lib64,/bin,/sbin), which includes the Docker socket. - A relative bind mount outside the repository.
- A
${NAME}without a value inimage, thecontext,dockerfileortargetofbuild,ports,expose,volumes,tmpfs,command,entrypoint,user,container_name,hostname,env_file,deploy,scale,profiles,restartorworking_dir.
Imported without effect
These are imported without their effect, with a note that says what to do instead.
| Compose key | Note |
|---|---|
healthcheck | NebulaCtrl uses its own health checks |
networks | Every service of an environment can reach each other |
configs | Not mounted; move the content into variables |
tmpfs | The path is on the container's disk and is cleared on restart |
traefik.* and caddy* labels | Ignored; add a domain in NebulaCtrl |
| Relative bind mounts | Files from the repository are not mounted; use a volume for data and an image or build for code |
| Absolute host paths outside the system paths | Dropped; use a volume |
working_dir, sysctls, ulimits, volumes_from | Not applied |
A restart policy produces no note, apart from one-shot handling.
Values
Every variable of every service is in one group, which the review shows and the API reports as origin.
| Group | origin | Meaning |
|---|---|---|
| You need to provide | empty with needsInput, or user | A secret, or a value the Compose file marks required with ${NAME:?message}, that nothing supplied. The import does not stage until you type a value or accept it as empty with I'll set it later. A value you typed has origin: user and stays in this group. The comment above the variable in its env_file, the :? message or the secret's name is the hint |
| Generated for you | generated | A secret the app owns and the file gives no real value for: none, empty or a placeholder. It becomes ${{ secret(32) }}, drawn when the variable is written and sealed. Turn Generate a random value off to move a sealed secret to You need to provide. Another generated name stays empty under From your compose file |
| From your compose file | compose | A literal value the file gives. It is sealed when its name suggests a secret. A secret that is committed in the file is already in the repository's history, and the review says to rotate it |
| Linked | reference or database | A database URL, credential or host rewritten to a reference, shown as KEY → service |
A variable with no value, written without = or as an unset ${NAME}, that is neither secret nor required has origin: empty and needsInput: false. The review lists it under From your compose file, and the import does not wait for it. A variable written as an empty string keeps origin: compose.
What is generated. A name is generated only when the app can own the value: a whole word SECRET, SECRETS, PASSWORD, PASSWORDS, PASSWD, PASSPHRASE, PASS, PWD or SALT, or KEY beside a master, encryption, signing, session, cookie, JWT, app, auth, nonce or HMAC word. A name that someone else issues is never generated: client secrets, OIDC and OAuth, webhook secrets, API keys, tokens, SMTP, LDAP, Stripe, S3, AWS and access keys. A Compose secrets: entry is generated only when its name is clearly the app's own: a master, encryption, signing, session, cookie, JWT or HMAC word beside key or secret, secret beside key, a salt, or the password of a database the import creates. A ${NAME:?message} is never generated. A variable that needs input is generated only when you ask for it, because a generated value would be one that an identity provider, a bank or a mail server has never heard of.
A placeholder counts as no value, whatever its case: changeme, change-me, change_me, replace-me, replace_me, replaceme, todo, tbd, ..., …, a value in angle brackets, a value that starts with your-, your_ or your , and three or more x with only -, _, . or spaces between them.
A name is sealed as a secret when it contains SECRET, PASSWORD, PASSWD, PASSPHRASE, PASS, TOKEN, KEY, DSN or CREDENTIAL, or the plural of one, as a whole word between underscores. A name ending in _FILE is not sealed, and a URL value with a password in it is. A reference is secret only when the key it names is: POSTGRES_DB of a database service is not.
Rewrites
A rewritten value replaces the Compose value and shows in the review next to the value it replaced.
- A database URL whose host is an alias of a managed database service, with the scheme of that engine, becomes the database's connection reference:
${{ db.DATABASE_URL }}for PostgreSQL,${{ db.REDIS_URL }}for Valkey,${{ db.MYSQL_URL }}for MySQL and${{ db.MONGODB_URL }}for MongoDB. A URL with a query or a fragment, or a Valkey database index other than 0, keeps its shape, with the user, password, host, port and database name replaced by references. - A service name used as an address, such as after
://,@,host=,server=oraddr=, before:port, or at the start of a value without spaces, or after a comma in one, whose key has the wordHOST,HOSTS,HOSTNAME,ADDR,ADDRESS,URL,URI,ENDPOINT,SERVER,DSN,BROKERorCONNECTION, becomes${{ host(NAME) }}. NebulaCtrl matches the service name,container_name,hostnameand network aliases. A name that points at a service you did not import is left alone, with a note. A name that two services share is left alone. - A credential that equals one a database container declared becomes the matching variable of the database service, when the variable's name says which credential it is (user, password or database), or when both read the same
${NAME}. - A secret file that a replaced database container read, such as the file behind
POSTGRES_PASSWORD_FILE, becomes a reference to the database's password key in every service that mounts it. Its user secret and database name secret likewise become references to the user and database keys. - A shared
${NAME}that has no value and that NebulaCtrl would generate becomes one variable on the first service that uses it, and a reference to it from the others. A${NAME}that has a value is copied to each service.
Interpolation
A ${NAME} that shapes the file itself, such as an image tag or a port, must be known before the import. The proposal lists each one and asks for a value. The value comes from, in this order: what you enter, the .env beside the file, the file's own default. A ${NAME} in a variable's value that has none follows the rules in Values.
Not ticked by default
A service is proposed and not ticked when it has a Compose profiles entry, when it is a one-shot, when it is scaled to zero replicas, or when ticking it would pass the limit of 20 services in the import. A blocked service is never ticked.
Limits
| Limit | Value |
|---|---|
| Services in one import | 20, counted across all selected files |
| Compose files listed | 20 |
| Subfolders searched | 50 immediate subfolders of the searched folder |
| Compose file size | 256 KiB |
| Services in one Compose file | 200 |
| Published and exposed ports in one file | 256 after ranges expand |
| Variables per service | 100 |
| Secret files per service | 20 |
| Volumes per process | 5, of 5Gi each |
| Release command | 8 KiB |
Ticking more than 20 services is refused with N services are ticked, but one import holds at most 20; untick M. A service name is at most 30 characters.
Choices over the API
The preview and the import take the same choices object. Services are keyed <file>#<composeName>, such as compose.yaml#web.
| Choice | Meaning |
|---|---|
choices.files | Paths of the files to import. Left out selects the default, and an empty list selects nothing |
choices.values | Values for the files' ${NAME} interpolations, by name, for every selected file |
choices.generate | Per ${NAME}, false leaves an offered value empty |
choices.acceptEmpty | <serviceKey>#KEY entries that stay empty |
choices.services.<key>.include | Tick or untick the service |
choices.services.<key>.name | The service's name |
choices.services.<key>.database | managed or image, for a database image |
choices.services.<key>.runAsUser | The user ID of an image service, or 0 to allow root |
choices.services.<key>.values | Values for the service's variables that need input. Sealed on import, never echoed or logged |
choices.services.<key>.generate | Per variable, false leaves an offered variable empty and true generates a secret that needs input |
choices.services.<key>.accept | Decision ids accepted |
choices.services.<key>.releaseCommand | The release command, which settles a release-command decision |