Agora

Install

What `node ace add @adonis-agora/sail` registers, how `sail:install` derives the service list from package.json, start/env.ts and config/*.ts, and every file it writes — append-only, idempotent, Docker not required.

Setting sail up is two commands, and they do very different things.

node ace add @adonis-agora/sail   # registers the commands in adonisrc.ts
node ace sail:install             # scans the app and writes the wiring

add makes exactly one edit: it appends @adonis-agora/sail/commands to the commands array in adonisrc.ts. That line is what makes sail:install, sail:up, sail:info and the rest show up in node ace list. No provider is registered and no config file is written — sail is dev tooling, never boots with the app, has no runtime surface and no container bindings. It belongs in devDependencies.

sail:install scans the app, decides which services it needs, and wires everything: compose.yml, start/env.ts validations, .env / .env.example defaults, the varlock schema, an AGENTS.md section. Docker is not required — it only reads and writes files.

Flags

FlagEffect
--services=<name>Repeatable. Skips detection entirely and uses exactly these services. Valid: postgres, mysql, redis, mailpit, rustfs, seaweedfs, garage, minio.
--object-storage=<backend>Picks the object storage backend (rustfs, seaweedfs, garage, minio) and asks for object storage, even when the scan cannot tell whether the app uses it. Saved to package.json as sail.objectStorage.
--exclude=<name>Repeatable. Services detection must leave out — a service name, or object-storage for the whole slot. Saved to package.json as sail.exclude, so a plain re-run does not bring them back.
--jsonMachine-readable report on stdout. Defaults to on inside an AI coding agent.

There is no --force and no --dry-run: every write is append-only and re-running is always safe, so neither has anything to force or preview.

The detection pipeline

Selection is resolved in strict precedence order:

  1. --services flags win over anything detected. An unknown name fails with Unknown service(s): … plus the valid list. The scan still runs — its env coverage and notes drive the wiring either way.
  2. Otherwise the scan runs over package.json, start/env.ts and every config/*.ts / config/*.js. Missing files count as absent, never as errors — a fresh checkout scans fine.
  3. Nothing found → a multi-select prompt listing every service with their summaries, or, when sail cannot prompt (AI agent, no TTY), a failure: see below.

1. package.json dependencies

dependencies and devDependencies are merged and matched against this table:

DependencyService
pgpostgres
mysql2, mysqlmysql
@adonisjs/redis, ioredis, bullmqredis
@adonisjs/mailmailpit
@adonisjs/drive, flydrive, @aws-sdk/client-s3object storage — the backend from sail.objectStorage, rustfs by default

A dependency is the weakest signal sail has: it says a driver is installed, not that the app uses it — which is why a config can veto it (step 6).

2. config/database.ts

Parsed first — the pg/mysql tie-break, the Agora lucid selections and the vetoes all depend on it. Sail reads client: '…', falling back to connection: '…':

ValueResult
pg, postgrespostgres
mysql, mysql2mysql
better-sqlite3, sqlite3, sqliteno container — note: "uses SQLite — no database container needed"
libsql, mssqlno container — sail ships no image for them

Every env.get('…') / process.env.… read in the file is collected for the env coverage report.

3. The other first-party configs

FileEvidence requiredService
config/redis.tsthe file exists and is non-empty after comment strippingredis
config/mail.tstransports.smtp( / an smtp: mailer key / SMTP_HOST / SMTP_PORTmailpit
config/drive.tsservices.s3( / disks.s3( / disk: 's3' / AWS_ACCESS_KEY_ID / S3_BUCKET / S3_ENDPOINT — unless the default disk comes from the environment, see belowobject storage

config/queue.ts is recognised as first-party too, but only contributes env reads.

4. Any other config/*.ts

Everything else — first-party extras and @adonis-agora/* configs (telescope, media, authz, durable…) — is read for active driver selections. Those packages consume named Lucid/Redis/Drive connections instead of env vars, so the selection is the only evidence there is:

PatternSignal
store: 'lucid', default: 'lucid', active: 'lucid'the configured database (postgres or mysql, per step 2)
store: 'redis' / default: 'redis' / transports.redis( / admissions.redis( / drivers.redis( / bullmqredis
disk: 's3', or a disks.s3( / services.s3( in a file that does not pick its disk from the environmentobject storage

Selections that are defined but not active become notes, not evidence — sail will not provision a container nobody selected. A stores.lucid( / stores.redis( that is defined while another store is active, a store: 'memory' in a config that also knows lucid, a lucid selection with no pg/mysql connection configured, or a lucid selection on SQLite each produce a note telling you exactly what to flip and to re-run install.

One shape gets a different note: when the active store is read from the environment (default: env.get('LOCK_STORE'), which is how config/lock.ts, config/limiter.ts and config/session.ts ship), the answer lives in .env and no amount of reading the file settles it. Sail says so and points at --services=, rather than telling you to select something you may already have selected.

The stock config/drive.ts has the same shape — default: env.get('DRIVE_DISK') — and DRIVE_DISK is usually fs in development. So an s3 disk in a file that picks its disk from the environment provisions nothing; install notes "config/drive.ts picks the disk from the environment (DRIVE_DISK) — sail cannot tell whether s3 is the active one; pass --object-storage=rustfs if it is". The dependency evidence (@adonisjs/drive) is dropped with it. --object-storage (or sail.objectStorage in package.json) is how you say yes, once, for good.

Commented-out code never counts. Every file is stripped of block and // comments first, by a quote-aware scanner that spares // inside strings and the :// of URLs. The Agora stubs document alternatives in comments (// lucid: storage.lucid(…)), sometimes trailing after real code — unstripped, one of those would read as an active selection and provision the wrong service.

5. start/env.ts and loose env reads

A DRIVE_DISK declaration whose enum admits only 's3' is object storage evidence (via: 'env'); one that admits 's3' among others (['fs', 's3'], the stock shape) is the same undecided case as above. A bare read of REDIS_HOST / REDIS_PORT anywhere in the scanned configs implies redis, SMTP_HOST / SMTP_PORT implies mailpit, AWS_ACCESS_KEY_ID / S3_BUCKET / S3_ENDPOINT implies object storage (reads inside an env-selected drive config excepted) — which is how hand-rolled clients (a raw ioredis instance, say) still get a container.

6. Vetoes and the pg/mysql tie-break

A dependency contradicted by its own config is dropped, with a note, instead of provisioning an idle container: pg installed while config/database.ts is on better-sqlite3 gives "package.json lists pg but config/database.ts uses sqlite — skipping postgres"; a config/drive.ts with no s3 disk skips object storage; a config/mail.ts with no smtp mailer skips mailpit. The veto only removes dependency-only evidence — config and env evidence always survive it — and it never applies when the owning config file is absent.

Postgres and MySQL are then made mutually exclusive: when both survive, the client in config/database.ts wins, and with no config postgres wins by default, with a note telling you to pass --services to override.

Last, anything listed in sail.exclude (below) is taken out, with a note.

Preferences in package.json

Choices that should outlive one run live under a sail key in the app's package.json — committed, so the whole team gets the same stack from a plain node ace sail:install, and JSON, because install never boots the app to evaluate a TypeScript config:

{
  "sail": {
    "objectStorage": "seaweedfs",
    "exclude": ["mailpit"],
    "images": { "postgres": "postgres:16" }
  }
}
KeyEffect
objectStorageBackend for the object storage slot (rustfs, seaweedfs, garage, minio). Setting it also counts as asking for object storage. --object-storage writes it.
excludeServices detection never adds — a service name, or object-storage for the whole slot. --exclude appends to it. An explicit --services still wins.
imagesImage per service for newly generated blocks, e.g. a Postgres major or a PostGIS build. An existing compose.yml block is never rewritten.

Unknown keys or values are reported as notes and ignored — a typo never stops the compose file from being written. Install only writes the key when you pass --object-storage or --exclude, keeping the file's indentation.

pgvector

When a migration under database/migrations/ (any depth, .ts/.js/.sql) contains CREATE EXTENSION [IF NOT EXISTS] vector — any case, quoted or not — a new postgres block runs pgvector/pgvector:pg17 instead of postgres:17: the same Postgres major plus the extension, with the same data layout. A postgres:16 in sail.images becomes pgvector/pgvector:pg16; a custom image is left exactly as configured. For an existing postgres:17 block, install leaves the file alone and notes the one-line image swap instead.

What sail:install writes

Everything below is append-only and safe to re-run: hand edits, tuned ports and real passwords are never overwritten.

compose.yml

Created with the selected services, or — when the file already exists — merged: only missing services are appended, with their named volumes. Existing blocks, hand edits included, stay byte-identical. Published ports interpolate ${SAIL_POSTGRES_PORT:-5432}-style defaults so the committed file is identical in every worktree and on every machine — see Worktrees for the runtime offsets and Services for the catalogue and compose anatomy.

start/env.ts

Keys that a selected service needs, that the file does not declare yet, and that have a stock first-party shape are appended under the comment Variables for @adonis-agora/sail local services:

KeysValidation
DB_HOST, REDIS_HOST, SMTP_HOSTEnv.schema.string({ format: 'host' })
DB_PORT, REDIS_PORT, SMTP_PORTEnv.schema.number()
DB_PASSWORD, REDIS_PASSWORDEnv.schema.string.optional()
DB_USER, DB_DATABASEEnv.schema.string()
AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION, S3_BUCKETEnv.schema.string.optional()
S3_ENDPOINTEnv.schema.string.optional({ format: 'url', tld: false })

The object storage keys are optional on purpose: production and CI often reach S3 through an IAM role or differently named keys, and a required validation sail added for local development would stop those boots.

And they follow the names your s3 disk reads. Sail parses the s3 disk in config/drive.ts: accessKeyId: env.get('S3_ACCESS_KEY') makes the key S3_ACCESS_KEY (with the same optional shape), a literal such as region: 'us-east-1' makes sail write no region at all, and a forcePathStyle: env.get('S3_PATH_STYLE') gets true. Only properties the disk does not mention fall back to the stock names above — so an app whose disk reads S3_* never gets an AWS_* key it does not use.

Keys outside that map — notably MAIL_MAILER and DRIVE_DISK, whose shape is an app-specific enum — are only reported ("sail has no stock validation for these, add them by hand"), never guessed. Install also prints a coverage line: keys declared, keys the configs use, keys missing.

.env and .env.example

Main-checkout defaults (base ports, offset 0) are added only for keys missing from both files: a key you already set is never rewritten, so a tuned value survives every re-run. Secrets — any key with PASSWORD or SECRET in its name — are written to .env but left empty in the committed .env.example. Both files always end with a newline afterwards, so the next echo KEY=value >> .env lands on a line of its own.

Only dotenv files that already exist are written. On a fresh clone — where .env is gitignored and only .env.example is checked in — the defaults land in the example and .env is not created. Copy it first (cp .env.example .env) if you want them where the app reads them.

DB_HOST=127.0.0.1                   # postgres (mysql: DB_PORT=3306)
DB_PORT=5432
DB_USER=sail
DB_PASSWORD=password
DB_DATABASE=app
REDIS_HOST=127.0.0.1                # redis
REDIS_PORT=6379
REDIS_PASSWORD=
SMTP_HOST=127.0.0.1                 # mailpit
SMTP_PORT=1025
AWS_ACCESS_KEY_ID=sail              # rustfs (names follow config/drive.ts)
AWS_SECRET_ACCESS_KEY=password
AWS_REGION=us-east-1
S3_BUCKET=local
S3_ENDPOINT=http://127.0.0.1:9000

These are base-port values. In a linked worktree the live ports differ: sail:up / sail:sync-env write the resolved ones into .env.local and .env.test.local, which Adonis loads over .env. Ask sail:info --json instead of reading ports from .env.

Install verifies these on disk rather than trusting the codemods' return: they swallow their own failures — no @adonisjs/assembler, no tsconfig.json at the app root, a start/env.ts without Env.create, a .env that does not exist — so a file that did not change is reported as not written with the snippet to paste, never as added.

If the codemods are unavailable or throw, install does not fail — env wiring must never block the compose file. You get the exact hand-edit snippet instead, surfaced as validationsSkipped / variablesSkipped in the JSON report.

.env.schema (varlock only)

Written only when varlock is detected — a committed .env.schema, or the varlock package in dependencies. Sail declares the selected services' connection keys inside # sail:schema:start / # sail:schema:end markers, appending only keys not already declared anywhere in the file: your types and docs always win. Details in Varlock.

AGENTS.md

A section wrapped in <!-- sail:start checksum=… --> / <!-- sail:end --> markers is created or appended, and refreshed in place on later runs only while it still matches the checksum — running install twice produces a byte-identical file, and a section you translated or reworded is never reset (install reports kept). It stays deliberately generic (no worktree names, no concrete ports), because the file is committed and shared, and points agents at sail:info --json for live values. See Agents.

Output

A human run prints the compose action, the full scan, the per-artefact actions, the notes, the resolved stack info, and Next: node ace sail:up.

compose.yml created with postgres, redis — /app/compose.yml
CONFIG SCAN (start/env.ts + config/*.ts)
  postgres — package.json:pg, database.ts:pg client, telescope.ts:lucid store selected (pg)
  start/env.ts declares 12 keys, configs use 14, missing: DB_HOST, DB_PORT

With --json (implicit inside an agent) the whole run is one object:

{
  "composeFile": "/app/compose.yml",
  "compose": "created with postgres, redis",
  "services": ["postgres", "redis"],
  "detection": [
    { "service": "postgres", "via": "dependency", "detail": "package.json:pg" },
    { "service": "redis", "via": "config", "detail": "redis.ts:redis config" }
  ],
  "projectName": "myapp-feature-login",
  "portOffset": 317,
  "agentsMd": "created",
  "config": "unchanged",
  "varlock": "skipped (no varlock detected)",
  "env": { "validations": ["DB_HOST", "DB_PORT"], "variables": ["DB_HOST", "DB_PORT"] },
  "notes": []
}

compose is one of created with …, updated (added …) or already up to date; agentsMd and varlock report created / updated / unchanged (agentsMd also kept (edited by hand — …), varlock also skipped (no varlock detected)); config is updated when --object-storage / --exclude changed package.json. notes carries every scan hint plus, when object storage is selected and the s3 disk lacks them, the one edit sail refuses to make for you: "add endpoint: env.get('S3_ENDPOINT') and forcePathStyle: true to the s3 disk in config/drive.ts" — the stock drive stub has neither, and every local backend needs both. The bucket itself is created by sail:up.

When nothing is detected

Without a TTY — or inside an AI agent, where sail never prompts — the command exits 1 with:

Could not detect any service from package.json or config/*.ts
Re-run with explicit services, e.g. node ace sail:install --services=postgres --services=redis (valid: postgres, mysql, redis, mailpit, rustfs, seaweedfs, garage, minio)

In JSON mode the same pair is emitted as { "error": …, "hint": … }. Aborting the interactive prompt produces the equivalent failure; selecting nothing exits cleanly with "No services selected — nothing to do."

Re-running and adding a service later

Just run it again — there is nothing to undo first:

node ace sail:install                      # re-detect after installing a package
node ace sail:install --services=redis     # add one explicitly
node ace sail:install --object-storage=garage  # add object storage, on Garage

Re-running merges the new service into compose.yml, appends only the still missing env keys and validations, refreshes the AGENTS.md block in place, and touches nothing else; when nothing is missing it prints already up to date / unchanged across the board.

--services replaces the selection for that run but never subtracts: services already in compose.yml stay. Remove one by deleting its block from compose.yml (run sail:down --volumes first if you want its data gone), and add it to sail.exclude (--exclude=<name>) so detection does not bring it back. The same goes for switching object storage backends: compose.yml keeps the one it runs until you delete its block — install tells you so instead of running two.

Next: Commands for the everyday workflow, or Troubleshooting when a service you did not expect gets detected.

On this page

Partner

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

Become a partner →