Agora

Services

The services sail can run — images, ports, credentials, healthchecks and the env keys each one owns, the four object storage backends — plus the anatomy and merge rules of the generated compose.yml.

Sail ships a fixed catalog of services — four of them interchangeable object storage backends, of which a stack runs one. There is no plugin API and no custom service registry: the catalog is a data table in the package, and everything else — detection, compose generation, port planning, sail:info output, .env wiring — is derived from it. Anything outside the catalog you add to compose.yml by hand, and sail leaves it alone.

The catalog

ServiceImageHost port(s)Container portVolumeHealthcheck
postgrespostgres:17 (pgvector/pgvector:pg17 when a migration creates the vector extension)54325432sail-postgres:/var/lib/postgresql/datapg_isready -U sail -d app
mysqlmysql:833063306sail-mysql:/var/lib/mysqlmysqladmin ping -h 127.0.0.1 -ppassword
redisredis:7-alpine63796379sail-redis:/dataredis-cli ping
mailpitaxllent/mailpit:latest1025 (smtp), 8025 (ui)1025, 8025——
rustfsrustfs/rustfs:1.0.39000 (s3), 9001 (console)9000, 9001sail-rustfs:/datacurl -fsS http://127.0.0.1:9000/health
seaweedfschrislusf/seaweedfs:4.488333 (s3), 8888 (filer)8333, 8888sail-seaweedfs:/datacurl -fsS http://127.0.0.1:8333/healthz
garagedxflrs/garage:v2.4.13900 (s3)3900sail-garage:/var/lib/garage/garage status
miniocgr.dev/chainguard/minio:latest9000 (s3), 8900 (console)9000, 8900sail-minio:/datamc ready local

Every healthcheck uses interval: 5s, timeout: 5s, retries: 10. sail:up runs docker compose up --detach --wait, so the command only returns once those checks pass — you never need a sleep before migration:run.

Images are overridable per app with sail.images in package.json (see Install); that only affects blocks install generates from then on.

Object storage: four backends, one slot

rustfs, seaweedfs, garage and minio all serve the S3 API, the same local bucket and the same app env, so config/drive.ts does not care which one runs. New installs get RustFS; pick another with node ace sail:install --object-storage=<backend>, which also records it in package.json ("sail": { "objectStorage": "garage" }). The reasoning behind the default is in the repository README.

RustFSSeaweedFSGarageMinIO
LicenceApache-2.0Apache-2.0AGPL-3.0AGPL-3.0, repository archived
Bucket created bysail:up (one signed PUT)the container (S3_BUCKET)the container (--default-bucket)sail:up (mc mb)
Dashboardconsole, /rustfs/console/filer browser, /buckets/noneconsole
Credentialssail / passwordsail / passwordsail-garage-key / sail-garage-passwordsail / password
Idle RAM (measured)~30 MB~95 MB~5 MB~60 MB
Image size~420 MB~720 MB~100 MB~235 MB

Garage needs Docker Compose 2.23.1 or later: its config file is inlined in compose.yml through configs: … content:, since the image (FROM scratch, no shell) cannot template one. Its access key is longer because Garage rejects key ids under 8 characters and secrets under 16.

All four answer with region us-east-1 and need path-style URLs (forcePathStyle: true): http://127.0.0.1:9000/local/key, not http://local.127.0.0.1:9000/key.

Host ports: fixed per app, shifted per worktree

The host ports above are base ports, and the main checkout always uses them as-is — your app's Postgres is on 5432, its Redis on 6379, and so on, every time. Only a git worktree moves them: every service shifts by the same deterministic offset, so a worktree's stack runs next to the main checkout's without clashing; see Worktrees.

What separates two stacks is not the same for every resource:

Main checkout of app A vs. app BMain checkout vs. a worktree of the same app
Containers, volumes, networkseparate (project named after each app)separate (<app>-<worktree> project)
Host portsthe same base portsshifted by the worktree offset

So two different apps on one machine get their own containers and data, but their main checkouts ask for the same host ports — the offset is derived from the worktree directory name only, never from the app name. Run one app's stack at a time (node ace sail:down in the other first), or work on one of them from a worktree. sail:up catches the clash before starting anything and names the container holding the port; see Another stack holds a host port.

Credentials and dashboards

All services share one hard-coded development identity, Laravel Sail style — predictable on purpose so sail:install needs zero configuration:

Value
Usersail
Passwordpassword
Database / bucketapp (Postgres, MySQL), local (object storage bucket)

These credentials are for local development only. The stack binds to the host, so anything reachable on your machine can reach sail:password. Never point a staging or production app at a sail stack, and see Sharing before exposing a port to the network.

Browser UIs, at the resolved (offset-aware) host port:

DashboardURL in the main checkout
Mailpit inboxhttp://localhost:8025
RustFS consolehttp://localhost:9001/rustfs/console/
SeaweedFS filerhttp://localhost:8888/buckets/
MinIO consolehttp://localhost:8900

node ace sail:info prints them with the current worktree's ports already applied, so you never compute an offset by hand. Garage has no web UI.

Container-side these arrive as POSTGRES_USER/POSTGRES_PASSWORD/POSTGRES_DB, MYSQL_USER/MYSQL_PASSWORD/MYSQL_DATABASE/MYSQL_ROOT_PASSWORD (root shares the same password), RUSTFS_ACCESS_KEY/RUSTFS_SECRET_KEY, AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY/S3_BUCKET (SeaweedFS), GARAGE_DEFAULT_ACCESS_KEY/GARAGE_DEFAULT_SECRET_KEY/GARAGE_DEFAULT_BUCKET and MINIO_ROOT_USER/MINIO_ROOT_PASSWORD. Redis and Mailpit take no environment at all — sail's Redis runs without auth, which is why the app env carries an empty REDIS_PASSWORD: it overrides a production password in .env that would otherwise be sent to a server that has none.

Env keys sail owns

Each service declares the app-side env keys it needs and the exact values for them. This is the single source of truth behind sail:info --env, the .env/.env.example defaults written by sail:install, the .env.schema items for varlock users, and the managed block sail:up / sail:sync-env write into .env.local.

ServiceKeyValue
postgresDB_HOST127.0.0.1
DB_PORT5432 + offset
DB_USERsail
DB_PASSWORDpassword
DB_DATABASEapp
mysqlDB_HOST127.0.0.1
DB_PORT3306 + offset
DB_USERsail
DB_PASSWORDpassword
DB_DATABASEapp
redisREDIS_HOST127.0.0.1
REDIS_PORT6379 + offset
REDIS_PASSWORDempty — left out when start/env.ts declares it required, since an empty value would fail boot
mailpitSMTP_HOST127.0.0.1
SMTP_PORT1025 + offset
object storageAWS_ACCESS_KEY_IDsail (Garage: sail-garage-key)
AWS_SECRET_ACCESS_KEYpassword (Garage: sail-garage-password)
AWS_REGIONus-east-1
S3_BUCKETlocal
S3_ENDPOINThttp://127.0.0.1: + the backend's s3 port + offset (MinIO: http://localhost:9000)

The object storage keys are the stock names; sail writes them under the names your config/drive.ts s3 disk actually reads — accessKeyId: env.get('S3_ACCESS_KEY') gets S3_ACCESS_KEY=sail, a literal region gets no key at all, and forcePathStyle: env.get('S3_PATH_STYLE') gets S3_PATH_STYLE=true. See Install.

Hosts are always 127.0.0.1: the app runs on the host, not in a container, so it talks to published ports rather than compose service names. Only these keys are sail's. Everything else in your .env — MAIL_MAILER, DRIVE_DISK, SESSION_DRIVER, app secrets — sail reports but never writes, because it has no stock shape to guess with.

postgres and mysql claim the same DB_* keys, and both always write DB_USER / DB_PASSWORD / DB_DATABASE along with the port, so .env.local never pairs sail's port with production credentials from .env. Enabling both is supported at the container level (two databases, two ports), but the merged app env only holds one DB_PORT — the later service in the list wins. Pick one as the app's database and reach the other through node ace sail:mysql / node ace sail:psql.

The generated compose.yml

sail:install writes a single compose.yml at the app root. With postgres, mailpit and rustfs selected it looks like this (Redis, MySQL and the other backends follow the same shape; Garage adds a top-level configs: entry):

# Managed by @adonis-agora/sail (`node ace sail:install`).
# Host ports interpolate `${VAR:-default}`: sail injects per-worktree ports at
# runtime, and a bare `docker compose up` falls back to the defaults. Hand
# edits are preserved — sail only ever appends missing services.
services:
  postgres:
    image: postgres:17
    ports:
      - ${SAIL_POSTGRES_PORT:-5432}:5432
    environment:
      POSTGRES_USER: sail
      POSTGRES_PASSWORD: password
      POSTGRES_DB: app
    volumes:
      - sail-postgres:/var/lib/postgresql/data
    healthcheck:
      test:
        - CMD
        - pg_isready
        - -U
        - sail
        - -d
        - app
      interval: 5s
      timeout: 5s
      retries: 10
  mailpit:
    image: axllent/mailpit:latest
    ports:
      - ${SAIL_MAILPIT_SMTP_PORT:-1025}:1025
      - ${SAIL_MAILPIT_UI_PORT:-8025}:8025
  rustfs:
    image: rustfs/rustfs:1.0.3
    ports:
      - ${SAIL_RUSTFS_PORT:-9000}:9000
      - ${SAIL_RUSTFS_CONSOLE_PORT:-9001}:9001
    environment:
      RUSTFS_ACCESS_KEY: sail
      RUSTFS_SECRET_KEY: password
      RUSTFS_CONSOLE_ENABLE: "true"
    volumes:
      - sail-rustfs:/data
    healthcheck:
      test:
        - CMD
        - curl
        - -fsS
        - http://127.0.0.1:9000/health
      interval: 5s
      timeout: 5s
      retries: 10
volumes:
  sail-postgres: null
  sail-rustfs: null

Port interpolation. Every published port is ${SAIL_<SERVICE>_PORT:-<base>}:<container>. Sail sets those variables — every one in the catalog (SAIL_POSTGRES_PORT, SAIL_MYSQL_PORT, SAIL_REDIS_PORT, SAIL_MAILPIT_SMTP_PORT, SAIL_MAILPIT_UI_PORT, SAIL_RUSTFS_PORT, SAIL_RUSTFS_CONSOLE_PORT, SAIL_SEAWEEDFS_PORT, SAIL_SEAWEEDFS_FILER_PORT, SAIL_GARAGE_PORT, SAIL_MINIO_PORT, SAIL_MINIO_CONSOLE_PORT) — in the environment of every docker compose call it makes, offset already applied; a bare docker compose up outside sail has them unset and falls back to the base ports. That is what makes the file worktree-agnostic and committable: byte-identical in every checkout, no .env-dependent ports, no per-developer diffs.

Project naming. The file declares no name:. Sail passes --project-name explicitly: the sanitized app name from package.json in the main checkout (shop), or <app>-<worktree slug> inside a linked worktree (shop-feature-login). One compose file, one project per worktree, fully isolated containers.

Volumes. Only stateful services get one; Mailpit deliberately has none. The names in the file (sail-postgres, …) are compose-relative — Docker prefixes them with the project, so the real volume is shop-feature-login_sail-postgres. Two worktrees never share data.

Networks. None are declared. Compose creates its own <project>_default network, and since the app runs on the host, nothing needs to join it.

What re-running sail:install does

sail:install is append-only. On an existing file it parses the YAML, adds the fragments for selected services that are not already present, registers any missing volume, and writes the document back:

SituationResult
Service missing from the fileFragment appended, reported as added
Service already presentUntouched, even if you edited it
Hand-written service of your ownUntouched
Comments, key order, formattingPreserved (the YAML document is edited in place, not regenerated)
No compose.yml at allGenerated from scratch with the header

So your edits survive: pin postgres:16, add POSTGRES_INITDB_ARGS, mount an init SQL script — install never reverts any of it. How services get selected (dependency scan, config/*.ts inspection, --services) is covered in Install.

Changing an image tag does not migrate the data. A sail-postgres volume written by postgres:17 will make postgres:16 refuse to start. Recreate the volume with node ace sail:down --volumes (destroys all local data for that worktree) and re-run your migrations and seeders.

Adding your own service is just YAML. To make its host port shift per worktree too, reuse the interpolation scheme with your own variable:

  meilisearch:
    image: getmeili/meilisearch:v1.11
    ports:
      - ${MY_MEILI_PORT:-7700}:7700

Sail starts and stops it with the stack (it targets the whole file), but it never shows up in sail:info, gets an env key, or receives a computed port.

Service specifics

Mailpit. No credentials, no volume, no healthcheck — ready as soon as it is up. The app sends to SMTP_HOST/SMTP_PORT with no auth and no TLS; every message is captured, nothing leaves your machine, and you read it in the inbox UI on the ui port. With no volume, sail:down empties the mailbox.

Object storage. The local bucket exists once sail:up returns. SeaweedFS (S3_BUCKET) and Garage (--single-node --default-bucket, which also lays out the one-node cluster and imports the key) create it themselves at boot, skipping whatever is already done; for RustFS and MinIO, which cannot, sail:up runs one idempotent step inside the container after the healthchecks pass — a SigV4-signed PUT /local with the image's own curl, and mc mb --ignore-existing. It runs on every up, reads the credentials from the container's environment (so hand-edited ones still work), and a failure fails up with the container's output.

What sail does not do is edit config/drive.ts. The stock s3 disk has neither an endpoint (so the SDK would talk to AWS) nor forcePathStyle: true, so install prints, only for what is missing:

Object storage: add `endpoint: env.get('S3_ENDPOINT')` and `forcePathStyle: true`
to the s3 disk in config/drive.ts — the local backend needs both

MinIO stays for existing stacks, but MinIO stopped publishing community images in October 2025 and archived its repository in April 2026, so minio/minio can no longer be pulled. New MinIO blocks use Chainguard's free rebuild, cgr.dev/chainguard/minio:latest; install notes an existing minio/minio image and the two ways out (that image, or another backend).

Postgres / MySQL / Redis. sail:psql, sail:mysql and sail:redis open the service's own client inside the container (docker compose exec), so they use container ports and the built-in credentials and are immune to the host-side offset. All three are stateful: a sail:down without --volumes keeps your tables, queues and cached sessions. See Commands.

See also: Install for how services are selected and wired, Worktrees for the port offsets, and Troubleshooting when a port or healthcheck misbehaves.

On this page

Partner

Your product as the official integration for Sail, right here.

Become a partner →