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 wiringadd 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
| Flag | Effect |
|---|---|
--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. |
--json | Machine-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:
--servicesflags win over anything detected. An unknown name fails withUnknown service(s): …plus the valid list. The scan still runs — its env coverage and notes drive the wiring either way.- Otherwise the scan runs over
package.json,start/env.tsand everyconfig/*.ts/config/*.js. Missing files count as absent, never as errors — a fresh checkout scans fine. - 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:
| Dependency | Service |
|---|---|
pg | postgres |
mysql2, mysql | mysql |
@adonisjs/redis, ioredis, bullmq | redis |
@adonisjs/mail | mailpit |
@adonisjs/drive, flydrive, @aws-sdk/client-s3 | object 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: '…':
| Value | Result |
|---|---|
pg, postgres | postgres |
mysql, mysql2 | mysql |
better-sqlite3, sqlite3, sqlite | no container — note: "uses SQLite — no database container needed" |
libsql, mssql | no 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
| File | Evidence required | Service |
|---|---|---|
config/redis.ts | the file exists and is non-empty after comment stripping | redis |
config/mail.ts | transports.smtp( / an smtp: mailer key / SMTP_HOST / SMTP_PORT | mailpit |
config/drive.ts | services.s3( / disks.s3( / disk: 's3' / AWS_ACCESS_KEY_ID / S3_BUCKET / S3_ENDPOINT — unless the default disk comes from the environment, see below | object 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:
| Pattern | Signal |
|---|---|
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( / bullmq | redis |
disk: 's3', or a disks.s3( / services.s3( in a file that does not pick its disk from the environment | object 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" }
}
}| Key | Effect |
|---|---|
objectStorage | Backend for the object storage slot (rustfs, seaweedfs, garage, minio). Setting it also counts as asking for object storage. --object-storage writes it. |
exclude | Services detection never adds — a service name, or object-storage for the whole slot. --exclude appends to it. An explicit --services still wins. |
images | Image 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:
| Keys | Validation |
|---|---|
DB_HOST, REDIS_HOST, SMTP_HOST | Env.schema.string({ format: 'host' }) |
DB_PORT, REDIS_PORT, SMTP_PORT | Env.schema.number() |
DB_PASSWORD, REDIS_PASSWORD | Env.schema.string.optional() |
DB_USER, DB_DATABASE | Env.schema.string() |
AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION, S3_BUCKET | Env.schema.string.optional() |
S3_ENDPOINT | Env.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:9000These 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_PORTWith --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 GarageRe-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.
Getting Started
Prerequisites, installing sail into an existing AdonisJS app, the first boot, and the everyday loop — with pointers to the deep pages.
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.