AdonisJS is the rare Node framework that ships with opinions: a real ORM (Lucid), a migration system, environment validation, and a defined project structure. That makes production deploys more predictable than the average Express app — but there are still four things you have to get right: the compile step, the environment contract, the database connection, and the order you run migrations in. Let's walk through all of it with AdonisJS 6.
Step 1: Understand what the build produces
AdonisJS 6 is TypeScript-first and does not run TypeScript in production. You compile:
node ace buildThis writes a build/ directory containing compiled JavaScript, your public/ assets, and — importantly — its own package.json. Production dependencies are installed *inside* that directory, and the server starts from there:
cd build
npm ci --omit=dev
node bin/server.jsIf you've only ever run node ace serve --watch locally, internalize this now: production runs build/bin/server.js, not ace serve. Getting the start command wrong is the single most common AdonisJS deploy failure.
Step 2: The environment contract
Adonis validates environment variables at boot via start/env.ts. If a required variable is missing or malformed, the process exits immediately with a clear error instead of limping along and failing on the first request. This is a feature — a bad deploy dies loudly in the build logs rather than quietly serving 500s.
The variables a typical production app needs:
NODE_ENV=production
PORT=3333
HOST=0.0.0.0
APP_KEY=<generated secret>
LOG_LEVEL=infoTwo of these bite people:
HOST=0.0.0.0— Adonis binds tolocalhostby default, which means the platform's router can reach the container but not your app. Bind to all interfaces.APP_KEY— used for cookie signing and encryption. Generate one locally withnode ace generate:key, then set it as an environment variable on the platform. Never commit it, and don't regenerate it per deploy or you'll invalidate every session and encrypted value.
If you add your own required variables, declare them in start/env.ts so validation covers them:
import { Env } from '@adonisjs/core/env'
export default await Env.create(new URL('../', import.meta.url), {
NODE_ENV: Env.schema.enum(['development', 'production', 'test'] as const),
PORT: Env.schema.number(),
HOST: Env.schema.string({ format: 'host' }),
APP_KEY: Env.schema.string(),
DATABASE_URL: Env.schema.string(),
})Step 3: Point Lucid at DATABASE_URL
Scaffolded Adonis projects expect discrete DB_HOST / DB_USER / DB_PASSWORD variables. Managed platforms hand you one DATABASE_URL connection string instead. Lucid sits on Knex, and Knex's pg client accepts a connection string directly, so the cleanest adaptation in config/database.ts is:
import { defineConfig } from '@adonisjs/lucid'
import env from '#start/env'
const databaseConfig = defineConfig({
connection: 'postgres',
connections: {
postgres: {
client: 'pg',
connection: {
connectionString: env.get('DATABASE_URL'),
ssl: { rejectUnauthorized: false },
},
migrations: {
naturalSort: true,
paths: ['database/migrations'],
},
pool: { min: 0, max: 10 },
},
},
})
export default databaseConfigTwo production notes on that config:
- Pool size. Managed Postgres plans cap concurrent connections (on PandaStack the free tier allows 50, Pro 300). Each app instance holds its own pool, so
max: 10across three instances is 30 connections. Budget accordingly before you scale replicas. min: 0lets the pool drain when idle, which plays nicely with platforms that scale idle apps down.
Step 4: Migrations, in the right order
Lucid migrations are forward-only in production:
node ace migration:run --forceThe --force flag is deliberate friction — Adonis refuses to run migrations when NODE_ENV=production without it. Two rules that will save you an outage:
- 1Never run
migration:freshormigration:resetagainst production. They drop tables. There is no confirmation prompt that will save you. - 2Run migrations as a separate step before the new version takes traffic, not inside the app's boot code. If migrations run at boot and you deploy two replicas, both race to apply the same migration. Lucid takes a lock, but the loser can still crash-loop on startup timing. A discrete release step is boring and correct.
For zero-downtime deploys, also keep migrations backward-compatible with the previous app version: add the column in this release, start writing it in the next, drop the old one after. Expand, migrate, contract.
Step 5: A production Dockerfile
PandaStack can auto-detect a Node app and build it with buildpacks, but Adonis's two-stage build (compile, then install prod deps inside build/) is explicit enough that I prefer a Dockerfile:
FROM node:22-slim AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN node ace build
FROM node:22-slim
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/build ./
RUN npm ci --omit=dev
USER node
EXPOSE 3333
CMD ["node", "bin/server.js"]The final image contains only compiled output and production dependencies — no TypeScript, no dev tooling, and it runs as a non-root user.
Step 6: Ship it on PandaStack
With the app containerized, the deploy itself is short:
- 1Provision the database. Create a managed PostgreSQL instance (14.x and 16.x are available) from the dashboard. Daily backups are scheduled automatically, retained 7 days on the free plan and longer on paid.
- 2Connect the repo. Add your AdonisJS repository as a container app. With the Dockerfile above in the repo root, it's picked up automatically; builds run in rootless BuildKit inside ephemeral Kubernetes job pods, and you can watch the build logs stream live — if
node ace buildfails on a type error, you'll see the exact compiler output. - 3Attach the database. Because the database is attached to the app,
DATABASE_URLis injected into the container environment automatically. No copying credentials between tabs, no secrets in the repo. - 4Set the remaining env vars.
APP_KEY,HOST=0.0.0.0,PORT, plus anything yourstart/env.tsrequires, in the app's environment settings. - 5Run migrations. Execute
node ace migration:run --forceas a release step or one-off command against the app before the new version serves traffic.
From then on, every git push triggers a build and deploy. Rollbacks and deployment history are available from the dashboard if a release goes sideways, and custom domains get SSL certificates automatically.
Framework gotchas worth knowing
- Trust the proxy. Your app sits behind the platform's ingress, so enable proxy trust in Adonis's HTTP config — otherwise
request.ip()returns the load balancer's address andsecurecookie logic misfires behind TLS termination. - Static assets live in
build/publicand Adonis serves them via the static middleware. Fine for an admin panel; for a heavily-trafficked marketing site, consider splitting that into a separate static site deploy. - Health checks. Add a trivial
/healthroute that returns 200 without touching the database. You want the platform judging liveness on the process, not on a saturated connection pool. - Cold starts on the free tier. Free-tier apps scale to zero when idle and cold-start on the next request. Perfectly fine for a staging environment or side project; for a latency-sensitive production API, use a paid tier that stays warm.
The whole thing, condensed
| Step | Command |
|---|---|
| Compile | node ace build |
| Install prod deps | cd build && npm ci --omit=dev |
| Migrate | node ace migration:run --force |
| Start | node bin/server.js |
| Deploy | git push |
AdonisJS gives you the production discipline — env validation, forward-only migrations, a real build artifact. The platform's job is to not fight that. If you want to see the full flow with a managed Postgres wired in automatically, try it at https://pandastack.io.