# Compose & multi-service apps
URL: https://openship.io/docs/guides/compose-multi-service.md

Run an app that's really several pieces at once — web, worker, database — from a docker-compose file or added by hand, and keep every service in sync.

import { Step, Steps } from 'fumadocs-ui/components/steps';

Some apps aren't one thing — they're a team. A website out front, a background **worker** doing slow jobs,
and a **database** holding the data. Think of a restaurant: the dining room, the kitchen, and the pantry are
three separate stations, but together they're one restaurant. Openship calls a set of services that run
together a **stack**, and it can run the whole stack for you.

Each service in the stack is its own little box (a **container**) with its own image, ports, storage, and
settings — but you manage them all from one place: the project's **Services** tab.

<Callout title="What you need first">
- A project in Openship. A stack is just a project that has more than one service.
- Your services either come from a **`docker-compose.yml`** file in your repo, or you **add them by hand** in
  the dashboard. Both are covered below.
- Nothing else — no Docker knowledge required to add a service from the catalog.
</Callout>

## Where your services live

Open your project and choose the **Services** tab. You'll see one row per service. Each row tells you, at a
glance:

- The service **name** (e.g. `web`, `worker`, `db`).
- A **Public** or **Internal** badge — Public means it has a web address people can visit; Internal means
  only the other services can reach it (a database is almost always Internal).
- A **status** dot: **Running**, **Stopped**, **Starting**, **Failed**, or **Disabled**.

Click any row to open that service's full screen. Repo updates preserve your explicit edits and apply
other changes automatically — see [how Compose changes merge](#when-the-repo-and-your-edits-disagree-drift).

## How services get into a stack

There are two ways, and you can mix them.

### 1. From a `docker-compose.yml` file

If your repo has a compose file, Openship reads it and turns **each service in the file into a service row** —
its image (or a build context and Dockerfile), ports, volumes, environment, command, restart policy,
`depends_on` links, and healthcheck. From then on those rows are what Openship deploys.

Openship tracks the compose fields it models: `image`, `build`, `dockerfile`, `ports`, `depends_on`,
`environment`, `volumes`, `command`, `restart`, and `healthcheck`. Everything else in your file — `networks`,
build args, labels, `profiles`, comments — is left untouched: Openship neither reads it nor rewrites it.

#### Compose file in a subfolder

Openship looks for the compose file at the project root. If yours lives somewhere else — say
`deploy/docker-compose/docker-compose.yml` — open **Compose file** in the deploy wizard and enter
the path, then hit **Scan**. Openship re-reads the repo and picks up the services it declares. You
can give either the file or the folder that holds it, and the filename doesn't have to be the
standard one (`stack.yml` works).

Relative `build:` contexts are resolved the way compose resolves them — against the compose file's
own folder — so a file in `deploy/docker-compose/` can use `build: ../../api` to build the `api`
directory at the repo root.

#### What `build:` means

`build:` is the docker build **context**: the directory docker builds from. `COPY` and `ADD` paths in
that service's Dockerfile are relative to it, `dockerfile:` is looked up inside it, and the
`.dockerignore` that applies is the one in that directory — exactly as `docker compose build` behaves.
Use `build: .` when the service really does build from the repo root.

Two consequences worth knowing:

- A service whose `build:` directory has no Dockerfile fails with a clear error. It does not fall back
  to a Dockerfile elsewhere in the repo — that would build a different image under this service's name.
- Compose `build.args` and `build.target` are not modelled. Build arguments come from the service's
  environment variables instead (each is passed as a `--build-arg`).

#### BuildKit

Modern Dockerfile syntax — `RUN --mount=type=cache`, `# syntax=` directives, heredocs — needs BuildKit.
Openship uses it automatically for a Dockerfile that requires it when it builds against a Docker socket,
and on a remote server whenever that host's Docker CLI has the `buildx` plugin. If a build fails with
`the --mount option requires BuildKit`, install the plugin on the build host (the `docker-buildx-plugin`
package, or Docker's official packages) and redeploy. The build log names which builder it used.

To keep it in the repo instead of setting it per project, declare it in
[`openship.json`](/docs/reference/openship-json):

```json
{ "composePath": "deploy/docker-compose/docker-compose.yml" }
```

The wizard field wins if you later change it there — the config file seeds the value, your edit is
what deploys.

<Callout title="Openship never writes to your repo" type="info">
Edits you make in the dashboard are stored by Openship and used on every deploy. Your `docker-compose.yml` is
never changed. If you want the file itself to reflect a change (so a teammate or a fresh clone sees it), edit
the file in your repo and push.
</Callout>

#### Database access during a Next.js build

If `next build` reports `getaddrinfo ENOTFOUND db`, followed by an error such as
`Failed to collect page data for /api/hours`, code evaluated during the build tried to resolve
the `db` hostname. Check the named route and its imported helpers for database initialization.

Use `"dependsOn": ["db"]` in `openship.json` (or `depends_on: [db]` in Compose). This orders
container startup; it does not start Postgres before image builds or attach the builder to the
services' network. Even an already-running database is not automatically reachable by its
service name from the builder.

For a route that needs data only when handling a request, move Payload initialization into the
existing `GET` or `POST` handler, or a helper called by that handler. Next.js can import route
modules during the build, so `export const dynamic = 'force-dynamic'` alone does not prevent
module-level `await getPayload(...)` or an eagerly created initialization promise from running.
Check imported helpers and configuration for the same side effects.

For example, this Next.js 15 / Payload 3 handler initializes Payload when a request arrives.
It assumes an `hours` collection; when adapting your own route, keep its existing authentication,
query options, access checks, and response shape:

```ts
import config from "@payload-config";
import { getPayload } from "payload";

export const dynamic = "force-dynamic";

export async function GET() {
  const payload = await getPayload({ config });
  const result = await payload.find({
    collection: "hours",
    overrideAccess: false,
  });
  return Response.json(result);
}
```

`overrideAccess: false` applies the collection's access rules; retain any existing authenticated
user context in your own queries. Keep the runtime connection string pointing at `db:5432`
when both running services share the project network.

If a page intentionally queries data during static generation, deferring an API handler will
not remove that requirement. Supply a data source reachable from the build environment, or
change that page to load its data at request time if that matches the application.

### 2. Added by hand

Any project can get extra services from a built-in catalog of common images — databases, caches, search,
queues, storage, and more — or from any Docker image you name.

<Steps>

<Step>

### Open the Add Service dialog

On the **Services** tab, press **Add Service**.

</Step>

<Step>

### Pick a service

You land on a catalog. Browse a category on the left (Databases, Caches, Search, Vector & AI, Queues &
Streams, Object Storage, Auth & Identity, Mail & SMTP, Runtimes), search by name, or choose **Custom image**
to type any image tag yourself. Click the one you want.

</Step>

<Step>

### Fill in the details

Openship pre-fills what it can. Check these:

- **Service name** — short and unique within the stack (e.g. `db`).
- **Ports** — the port(s) the container listens on, comma-separated (e.g. `5432`).
- **Image** — the Docker image and tag (e.g. `postgres:16-alpine`).
- **Persistent volumes** — optional `name → path` pairs so data survives restarts.
- **Environment variables** — settings the service needs.
- **Routing** — leave it **Internal only** (the right choice for databases and background workers), or switch
  it to **Publicly exposed** to give the service its own web address — a free subdomain or your own custom
  domain.

Press **Add service**.

</Step>

<Step>

### It deploys automatically

If the project already has a live deployment, adding a service **redeploys** the project so the new container
actually starts — you'll see a "deploying…" message. If the project has never been deployed yet, the service
is just **saved**, and you deploy the project yourself to bring it up.

</Step>

</Steps>

### Re-syncing after the compose file changes

You don't normally sync by hand — on every deploy Openship **re-reads the current Compose source** (the
repository for GitHub projects or `localPath` for local projects) and reconciles it against your service rows
automatically. GitHub pushes use the same path and skip the scan only when the changed-file list proves the
Compose file was untouched. From the terminal you can also force a sync from a specific file:

```bash
# Rebuild the stack's service rows from a compose file.
# Services in the stack but NOT in the file are removed — you'll be asked to confirm.
openship service sync --project my-app ./docker-compose.yml
```

<Callout title="Sync needs Docker Compose v2" type="warn">
`openship service sync` shells out to `docker compose config` to parse and validate the file, so Docker
Compose v2 must be installed where you run the command. If `docker` isn't found, the sync stops with a clear
message.
</Callout>

## Opening a service

Click a service row to open its screen. Across the top is a row of tabs:

- **Overview** — read-only facts, each shown only when it applies: **Network** (the internal address other
  services use to reach this one, the container ports, the published host port, the current IP, and the
  container ID — shown as a **Workspace ID** on Openship Cloud), **Configuration** (restart policy, command,
  depends-on), and **Volumes**.
- **Terminal** — a live shell inside the container. It only opens while the service is **Running** ("Start the
  service to open a shell." until then).
- **Logs** — the service's output, streamed as it happens.
- **Environment** — the service's environment variables. Edit them and press **Save environment**.
- **Settings** — the build and runtime fields you can change: name, image (or a build context and Dockerfile),
  ports, depends-on, volumes, command, restart policy, and an optional healthcheck — plus the enable toggle,
  the lifecycle buttons, and **Delete**. (Environment variables have their own **Environment** tab.)
- **Backup** — schedule and run backups. This tab shows only for compose services (the stateful containers),
  not for source-built sub-apps.

## Start, stop, restart

The lifecycle buttons live on the service's **Settings** tab. Which ones appear depends on the state:

- **Running** → **Stop** and **Restart**.
- **Stopped** → **Start**.
- **Disabled** (never started) → **Enable & start**, which turns the service on and deploys it.
- **Enabled** with a live deployment → **Redeploy**, which rebuilds **only this service** and leaves the rest
  of the stack running.

There's also **Disable Service** / **Enable Service** (a disabled service is skipped on deploy) and
**Delete** (tears the service down).

<Tabs items={['Dashboard', 'CLI']}>

<Tab value="Dashboard">
Open the service → **Settings** tab → press **Start**, **Stop**, or **Restart**.
</Tab>

<Tab value="CLI">
```bash
openship service start   --project my-app db
openship service stop    --project my-app db
openship service restart --project my-app db

# See what's actually running
openship service containers --project my-app
```
Target a service by its name or its `svc_…` id.
</Tab>

</Tabs>

## When the repo and your edits disagree: drift

On each deploy Openship merges the current Compose source with your saved service configuration,
comparing each field against the previously imported version:

- **base** — the compose values it last read in,
- **yours** — the service's current values (including your dashboard edits),
- **theirs** — what the repo file says right now.

| Repo changed? | You edited it? | What Openship does |
| --- | --- | --- |
| no | — | keeps your values |
| yes | no | **applies the repo's value** automatically |
| yes | yes | **keeps the edited field** and applies the other repo changes |
| new service in the repo | — | adds it automatically |
| removed from the repo | no | removes it only if it has no saved environment |
| removed from the repo | yes | keeps it until you explicitly delete it |

Routine Compose changes do not require **Accept upstream** or **Keep mine** before redeploying.
Saved project and service environment values remain in place. A key omitted from the repo is retained
as a local override, and an unresolved expression cannot replace the only saved value for that key.
Remove a saved variable explicitly from the environment editor when you no longer need it.

If an older version left a pending review, the next deploy reconciles it automatically. The legacy
resolution actions remain available: **Accept upstream** applies source configuration while preserving
saved environment values; **Keep mine** retains the current configuration.

<Tabs items={['Dashboard', 'CLI']}>

<Tab value="Dashboard">
Redeploy the project to apply current source changes. You can also resolve a review left by an older
version from its existing banner.
</Tab>

<Tab value="CLI">
```bash
# Accept a legacy source review; saved environment values are preserved
openship service drift accept --project my-app web

# Keep your edits and stop flagging the change
openship service drift keep --project my-app web
```
</Tab>

</Tabs>

## Ports & volumes: cloud vs self-hosted

How ports and volumes behave depends on **where** the stack runs:

- **Self-hosted / your own server (Docker):** full compose semantics. `host:container` port mappings are
  published on the host, and volumes (named or bind mounts) are mounted.
- **Openship Cloud:** only the **container** port matters — it's routed to your domain through the edge
  proxy, and a published *host* port is ignored. **Docker volume mounts don't apply.** A cloud service's data
  lives on its workspace disk, which survives restarts but is **not** carried across a rebuild unless you turn
on backups.

Compose port publishing and Openship-managed domain routing are separate
ownership boundaries. A domain is changed only when you explicitly configure a
project/service endpoint in Openship; an unrelated Caddy, nginx, or Traefik
vhost is never inferred from `ports` and is never rewritten. For an
Openship-managed domain on the same server, redeploy keeps its tracked host port
pinned so the route cannot silently move to a different upstream.

You'll see this in the interface too. When you set the stack up in the deploy wizard, a cloud project **greys
out the published (host) port and the volume fields** and notes that only the container port is routed.
Stateful images — databases like Postgres, MySQL, MariaDB, MongoDB, Redis or Elasticsearch, and any service
that declares a volume — get an amber reminder that their data isn't kept across a rebuild unless you turn on
backups. Later, on the **Services** tab, a cloud service's **Overview** shows a **Workspace ID** in place of
the Container ID.

## CLI

Every service action has a CLI command. All of them target a stack with `-p / --project` (id, slug, or name)
and a service by name or `svc_…` id.

```bash
openship service list    --project my-app                 # list the services
openship service get     --project my-app db              # show one service's config
openship service create  --project my-app cache \
  --image redis:7 --port 6379                             # add a service by hand
openship service sync    --project my-app ./docker-compose.yml
openship service start   --project my-app web             # start / stop / restart
openship service logs    --project my-app web --follow    # stream logs
openship service env set --project my-app web KEY=value   # set env vars (--secret, -e <env>)
openship service drift accept --project my-app web        # resolve drift
openship service delete  --project my-app cache           # remove a service
```

## Troubleshooting

<Callout title="I added a service but nothing is running" type="warn">
Adding a service saves it, but a container only starts on a deploy. If the project already had a live
deployment, Openship redeploys for you; if it didn't, open the project and deploy it (or use the service's
**Enable & start** button on its Settings tab).
</Callout>

<Callout title="`openship service sync` says docker wasn't found" type="error">
The sync command uses Docker Compose v2 to read your file. Install Docker (which includes `docker compose`)
on the machine where you run the command, then try again. You can always add or edit services in the
dashboard instead.
</Callout>

<Callout title="An old Compose review banner is still visible" type="info">
Redeploy to reconcile it with the current source. Your explicit overrides remain in effect and other
repo changes apply automatically. A genuinely missing required variable still needs a value in the
environment editor; the deployment error identifies its service and key.
</Callout>

## Related guides

<Cards>
  <Card title="Deploy from GitHub" href="/docs/guides/deploy-from-github" description="Get your repo — compose file and all — online in the first place." />
  <Card title="Core concepts" href="/docs/getting-started/core-concepts" description="The plain-language vocabulary: projects, services, deployments, and runtimes." />
  <Card title="Runtime model" href="/docs/architecture/runtime-model" description="The three places a stack can run — this machine, your server, or Openship Cloud." />
  <Card title="Data ownership" href="/docs/architecture/data-ownership" description="Where your service data lives when you self-host, and how backups protect it." />
</Cards>
