Skip to content

[compose.*]

[compose.<alias>] blocks point RunWisp at an existing docker-compose.yml. Every service in the compose file becomes an observable RunWisp service: per-container logs are streamed and indexed, restart policies are enforced, failures route through your notification rules, and you can trigger or stop containers from the Web UI and REST API — without rewriting anything.

The containers themselves keep running under Docker exactly as before; RunWisp is the supervisor and observability layer on top. For the worked walkthrough, see Migrating from docker-compose — this page is the key-by-key reference.

Drop one line next to your docker-compose.yml:

[compose.myapp]

That’s it. RunWisp searches the directory of runwisp.toml for compose.yaml, compose.yml, docker-compose.yaml, docker-compose.yml in that order — the same fallback docker compose itself uses — and imports every service in the first file it finds.

[compose.myapp]
file = "./docker-compose.yml" # default: auto-discovered next to runwisp.toml
include = ["api", "worker"] # subset; omit to import all
exclude = ["db"] # mutually exclusive with include
mode = "services" # "services" (default) | "stack"
group = "myapp" # UI group; defaults to the alias
project_name = "myapp" # docker compose -p; defaults to the alias
profiles = ["production"] # docker compose --profile
env_file = ["./.env.prod"] # docker compose --env-file
working_dir = "/opt/myapp" # cwd for compose invocation; defaults to dir of `file`
with_deps = false # default: --no-deps; true starts compose deps too
pull = "missing" # "missing" (default) | "always" | "never"
name_format = "{alias}.{service}" # RunWisp task naming; default shown

Got a stack of near-identical workers that all want the same policy? Instead of repeating a sub-table per service, set them once in [compose.<alias>.defaults]. Every imported service inherits those keys, and any per-service sub-table still wins:

[compose.stack]
include = ["worker-a", "worker-b", "worker-c"]
[compose.stack.defaults]
restart = "always" # applies to all three workers
start_retries = 5
[compose.stack.worker-a]
restart = "on_failure" # worker-a opts out; the other two stay "always"

This is worth knowing about because the compose-import default is restart = "on_failure" — a worker that exits 0 (batch-then-quit, memory-guard restart, idle timeout) counts as “done” and won’t be relaunched. If you’re migrating workers that carried restart: unless-stopped under plain Compose, a one-line [compose.<alias>.defaults] restart = "always" restores that behavior for the whole block.

defaults accepts the same keys as a per-service override (below). Its notify_on_failure / notify_on_success lists add routes to every service, on top of any a service declares for itself. Because defaults is a reserved sub-table name, a compose service literally named defaults is rejected with a rename hint. Block defaults aren’t available in mode = "stack" (there’s only one task there — set its knobs on the block).

Per-service sub-tables apply RunWisp knobs to a single compose service. The sub-table key has to match a compose service name — and one that survived your include / exclude filters:

[compose.myapp]
file = "./docker-compose.yml"
[compose.myapp.api]
restart = "always"
notify_on_failure = ["slack-prod"]
graceful_stop = "30s"
keep_runs = 100
env = { LOG_LEVEL = "info" } # layered on top of compose env at runtime
[compose.myapp.worker]
restart = "on_failure"
instances = 3 # three identical worker instances

Override sub-tables accept any subset of the [services.*] keys: restart, env, env_file, secrets, secrets_file, notify_on_failure, notify_on_success, graceful_stop, stop_signal, exit_codes, keep_runs, keep_for, instances, timeout, description, log_max_size, log_on_full, api_trigger, on_overlap, restart_delay, restart_backoff, healthy_after, start_retries, priority, autostart.

The host-process knobs — shell, umask, env_base, and user — are deliberately not accepted here: they configure a host shell run, while a compose service runs inside a container the runtime owns. (env_base in particular has nothing to choose between — a container never inherits the daemon’s environment either way.) (working_dir comes from the [compose.<alias>] block, not the per-service sub-table.)

One sharp edge: a compose service that happens to share a name with one of the scalar keys above (file, include, exclude, mode, group, project_name, profiles, env_file, working_dir, with_deps, pull, name_format) can’t get a sub-table — the loader rejects the config with an error asking you to rename the compose service.

If your docker-compose.yml puts services behind Compose profiles, Compose hides them by default — a service tagged profiles: ["workers"] simply doesn’t exist as far as docker compose is concerned until that profile is turned on. RunWisp is no different: it only sees the services Compose would, so a profile-gated service is invisible until you activate its profile.

You activate profiles with the profiles key, and that has to happen before include/exclude make sense. Think of it as two steps:

  1. profiles = [...] decides which services exist at all.
  2. include / exclude then pick from that activated set.

So this imports the worker service that lives behind the workers profile, and nothing else:

[compose.myapp]
file = "./docker-compose.yml"
profiles = ["workers"] # turn the profile on first…
include = ["worker"] # …then select from what it exposes

Two failure modes worth naming, because both look like “RunWisp ignored my service”:

  • include-ing a profiled service without activating its profile. The service isn’t in the activated set, so the include reference doesn’t resolve — runwisp validate flags it as an unknown service. Add the profile to profiles.
  • Activating a profile but forgetting it widens the default set. With profiles set and no include, RunWisp imports every now-visible service, profiled ones included. Use include (or exclude) to narrow it back down if you only wanted a subset.

A quick way to see what a given profiles value exposes before wiring it into RunWisp is to ask Compose directly:

Terminal window
docker compose --profile workers config --services

That prints exactly the service set RunWisp will choose from.

Each compose service becomes its own RunWisp service, named per name_format (default <alias>.<svc>). Here’s the exact command each instance runs under the hood:

docker compose -f <file> -p <project> [--profile ...] [--env-file ...]
run --rm --no-deps --service-ports --use-aliases
--name <project>_<svc>_<instance_index>
[--pull always|missing|never]
-e RUNWISP_INSTANCE_INDEX=<i>
-e KEY=VAL ... # your env + secrets overrides
<svc>

Why run --rm and not up: it keeps RunWisp’s “one process per instance slot” supervisor invariant uniform with every other backend. --service-ports and --use-aliases make the container behave exactly like its compose-declared self; --rm cleans the container on exit so every restart starts fresh.

One RunWisp service per [compose.<alias>], named <alias>:

docker compose -f <file> -p <project> up --abort-on-container-exit --no-log-prefix

The whole stack runs together; logs from all containers stream into a single RunWisp run; the run exits when any container exits. Per-service sub-tables, include, exclude, and instances are all rejected in stack mode — use per-service mode if you want per-service knobs.

Imported tasks get smarter defaults than the generic [services.*] baseline because compose users carry strong existing expectations:

Knob Compose-import default Reason
kind service Compose services are long-running.
restart on_failure Matches the unspoken expectation set by docker compose up.
group the compose alias Grouped together in the Web UI sidebar by default.
pull missing Behaviour of docker compose up.
graceful_stop compose stop_grace_period when set, else daemon default Honours the compose-declared grace window.
Container name {project}_{service}_{index} Matches docker compose ps output; docker logs keeps working.
Task name {alias}.{service} Override with name_format; must contain {service} (else collide).

User overrides win over every default via [compose.<alias>.<svc>].

instances = N produces N parallel docker compose run invocations. Each instance receives a stable RUNWISP_INSTANCE_INDEX=<i> env var (0..N-1) and a container name suffix matching the index so docker compose ps shows each container separately. Use the env var to self-shard workloads.

Parameterised instances (different env per slot) are deliberately out of scope — for that, declare one compose service per slot.

[services.X] or [tasks.X] can also point at a compose service directly, without bringing in the bulk [compose.<alias>] block:

# Long-running single service backed by compose:
[services.api]
compose_file = "./docker-compose.yml"
compose_service = "api" # defaults to the table key
# Cron task running a compose-defined image once:
[tasks.nightly-backup]
cron = "0 3 * * *"
compose_file = "./docker-compose.yml"
compose_service = "backup"

Running a command in a service: compose_mode

Section titled “Running a command in a service: compose_mode”

Set run alongside compose_service and RunWisp runs your command inside the service’s already-running containerdocker compose exec, not a new container:

[tasks.laravel-schedule]
cron = "* * * * *"
compose_file = "/srv/myapp/compose.yaml"
compose_service = "app"
run = "php artisan schedule:run"

That’s what most people want from a cron task attached to a running app: it executes in the live container, with that container’s env, code version, and credentials, and nothing new gets started.

compose_mode names the choice explicitly when you need the other one:

Value What happens
exec docker compose exec into the service’s running container. Requires run.
run docker compose run --rm — a fresh container. With run set, it runs your command; without, the service’s own.

The default is whichever one your config can actually mean: exec when run is set, run when it isn’t. So the snippet above needs no compose_mode, and neither does a plain compose_service task — while compose_mode = "run" gets you a fresh container running your command instead of the live one.

Two things to know before you lean on exec:

  • The container has to be up. If it isn’t, the run fails with container … is not running — visibly, as a failed run with that in the log, not a silent skip.
  • timeout can’t reach inside. Docker offers no way to cancel an exec, so when a run times out RunWisp kills the local client and the process inside the container carries on. Bound long work on the inside too — run = "timeout 3600 php artisan long-job". RunWisp warns at startup if you put a long-running service in exec mode, because there the leftover process compounds on every restart.

Your command runs under the container’s /bin/sh with fail-fast armed, the same as run on the host — so a multi-line script stops at the first failing line. The shell key isn’t accepted here: it configures a host process, and the host’s shell path says nothing about what exists in someone else’s image.

RunWisp owns supervision: when a container exits, the RunWisp service supervisor decides whether to restart based on the task’s restart policy and backoff curve. The compose-file restart: directive is not consulted — RunWisp is your supervisor now.

When the docker CLI is missing or the Docker daemon is unreachable, imported tasks remain visible in the UI but their runs fail with a clear system-log message pointing at install docs.