# Compose & multi-service apps
URL: https://synquic.tech/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. Synq 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 Synq. 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, Synq 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 Synq deploys.

Synq 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: Synq neither reads it nor rewrites it.

#### Compose file in a subfolder

Synq 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**. Synq 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.
Synq 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
[`synq.json`](/docs/reference/synq-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="Synq never writes to your repo" type="info">
Edits you make in the dashboard are stored by Synq 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 `synq.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

Synq 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:18-alpine`, what a new PostgreSQL service starts on).
- **Persistent volumes** — optional `name → path` pairs so data survives restarts.
  PostgreSQL 18 keeps its data under `/var/lib/postgresql/18/docker`, so the catalog mounts its volume at
  `/var/lib/postgresql` (not the older `/var/lib/postgresql/data`, which 18 refuses to start with). That
  path also works if you change the tag to 17 or older. Existing PostgreSQL services keep their image: a
  major version upgrade needs a dump and restore (or `pg_upgrade`), so Synq never changes it for you.
- **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 Synq **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.
synq service sync --project my-app ./docker-compose.yml
```

<Callout title="Sync needs Docker Compose v2" type="warn">
`synq 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), **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
synq service start   --project my-app db
synq service stop    --project my-app db
synq service restart --project my-app db

# See what's actually running
synq 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 Synq 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 Synq 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
synq service drift accept --project my-app web

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

</Tabs>

## Ports & volumes

On Synq a stack runs on Docker, on the Synq server or on one of your own servers, with full compose
semantics: `host:container` port mappings are published on the host, and volumes (named or bind mounts) are
mounted.

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

### When a host port is already taken

A host port is one namespace per server, shared by every project on it. What happens when the port you
want is already used by another app depends on who chose it:

- **A port you typed** in a service's **Ports** editor is never changed. Saving is refused with the
  name of the app or process that holds it, and the row offers **Auto-assign**.
- **Leave the published port empty** in the Ports editor and Synq picks it: the target port when it is
  free, otherwise the next free port above it.
- **Expose to the internet** on a database uses the engine's standard port (5432, 3306, ...) unless you
  type another; when the standard port is taken it uses the next free one.
- **One-click app templates** choose their own host ports. Installing two apps that want the same port on
  one server never fails: the second install, and a first deploy that finds the port in use, moves to the
  next free port.

Synq skips the ports it reserves, other projects' ports and routing claims, ports already chosen in the
same request, and anything listening on the server. It looks at most 100 ports above the wanted one and
never lands in the privileged range below 1024 (a taken 80 continues at 8080). The new port is saved on the
service, and Synq says so in the deploy log and in the dashboard, for example: `Port 8401 was in use by
project "Grocy"; this app now uses 8402.`

### External networks, extra hosts and stop grace period

Every service joins the stack's project network, where it answers to its service name. These settings
describe how a container sits on its host. They live in the service's `advanced` block, are
shown under **Settings → Networking → Host networking**, and are applied on the next deploy:

| Setting | `advanced` key | Notes |
| --- | --- | --- |
| External networks | `externalNetworks: [{ "name": "app_net", "aliases": ["app-api-1", "api"] }]` | Existing Docker networks the container also joins, with the names it answers to on each. Names are kept exactly as written; only names Docker can't resolve are refused. Synq never creates or removes these networks, and a deploy stops before touching the running container when one is missing on the server. `synq-*` project networks and `bridge`/`host`/`none` are refused. |
| Project network names | `networkAliases: ["shop-db"]` | More names on the project network, kept exactly as written (unlike **Network alias**, which is normalized to a DNS label). A migrated service keeps its old container name here. |
| Extra hosts | `extraHosts: ["host.docker.internal:host-gateway"]` | Compose `extra_hosts` (list or map form) is imported into this key. `host-gateway` resolves to the host's gateway address. |
| Stop grace period | `stopGracePeriod: "30s"` | Compose `stop_grace_period`, Docker's `StopTimeout`. A redeploy stops the old container with it and waits that long before starting the new one. Up to 3600 seconds. |

A host port pinned to one interface (`127.0.0.1:6379:6379`) is published on exactly that interface. External
networks declared in a compose file are still flattened into the project network on import; set them on the
service (a [migrated](/docs/guides/migrate-from-coolify-dokploy-dokku#what-an-adopted-service-keeps-from-its-container)
service gets them from its running container).

## 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
synq service list    --project my-app                 # list the services
synq service get     --project my-app db              # show one service's config
synq service create  --project my-app cache \
  --image redis:7 --port 6379                             # add a service by hand
synq service sync    --project my-app ./docker-compose.yml
synq service start   --project my-app web             # start / stop / restart
synq service logs    --project my-app web --follow    # stream logs
synq service env set --project my-app web KEY=value   # set env vars (--secret, -e <env>)
synq service drift accept --project my-app web        # resolve drift
synq 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, Synq 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="`synq 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 two places a stack can run: the Synq server or another server of yours." />
  <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>
