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. 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.
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.ymlfile 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, 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.argsandbuild.targetare 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:
{ "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.
Synq never writes to your repo
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.
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:
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
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 → pathpairs 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 (orpg_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.
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 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:
# 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.ymlSync needs Docker Compose v2
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.
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).
Open the service → Settings tab → press Start, Stop, or Restart.
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-appTarget a service by its name or its svc_… id.
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.
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
synq service drift accept --project my-app web
# Keep your edits and stop flagging the change
synq service drift keep --project my-app webPorts & 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
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.
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 serviceTroubleshooting
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, 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).
`synq 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.
Related guides
Deploy from GitHub
Get your repo — compose file and all — online in the first place.
Core concepts
The plain-language vocabulary: projects, services, deployments, and runtimes.
Runtime model
The two places a stack can run: the Synq server or another server of yours.
Data ownership
Where your service data lives when you self-host, and how backups protect it.
Build on another server
Build a project's image on a separate build server so heavy builds never slow production: set up a build server, choose Build on, and what happens to the image, the cache, cancels and failures.
Preview environments
Make a separate, safe copy of your project from a git branch — so you can try changes before they reach your real site.