Guides

Compose & multi-service apps

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.

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.

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.

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.

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:

{ "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.

Openship never writes to your repo

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.

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:

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.

Open the Add Service dialog

On the Services tab, press Add Service.

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.

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.

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.

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:

# 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

Sync needs Docker Compose v2

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.

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).

Open the service → Settings tab → press Start, Stop, or Restart.

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.

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
yesnoapplies the repo's value automatically
yesyeskeeps the edited field and applies the other repo changes
new service in the repo—adds it automatically
removed from the reponoremoves it only if it has no saved environment
removed from the repoyeskeeps 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.

Redeploy the project to apply current source changes. You can also resolve a review left by an older version from its existing banner.

# 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

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.

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

I added a service but nothing is running

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).

`openship service sync` says docker wasn't found

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.

An old Compose review banner is still visible

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.

On this page