How to deploy an AdonisJS application to production
Package an AdonisJS 7 application in a Docker image, set APP_KEY, run its migrations with --force before it takes traffic and connect it to PostgreSQL.
Updated
This guide takes an AdonisJS 7 application to production on your own server, with PostgreSQL and HTTPS. The Dockerfile, the variables and the migration step work on any host that runs containers, and the last sections deploy it with Tenkai, which runs the migrations once before the new version takes any traffic.
We tested it on 2 November 2026 with a new app from npm create adonisjs@latest and its Hypermedia starter kit, on AdonisJS 7.6, Lucid 22.4 and node:24-trixie-slim, so adapt the packages and the configuration to your own application.
Prerequisites
You need an AdonisJS 7 application in a Git repository with a CI such as GitHub Actions, made from any starter kit, and Node.js 24 with npm 11. Docker is useful to test the image on your machine, and the Tenkai part needs a project on a ready cluster.
Switch to PostgreSQL
The starter kits store their data in SQLite, in a file under tmp that each new container starts without. Install the PostgreSQL driver and remove the SQLite one.
npm install pg
npm uninstall better-sqlite3
Removing better-sqlite3 matters for the image too, because it compiles native code as it installs and npm ci fails in a slim image that has no Python or compiler.
In config/database.ts, import env from #start/env, set connection: 'pg' and uncomment the pg connection, which reads DB_HOST, DB_PORT, DB_USER, DB_PASSWORD and DB_DATABASE. Declare them in start/env.ts, so the server refuses to start when one is missing instead of failing on the first query.
// Database
DB_HOST: Env.schema.string({ format: 'host' }),
DB_PORT: Env.schema.number(),
DB_USER: Env.schema.string(),
DB_PASSWORD: Env.schema.string.optional(),
DB_DATABASE: Env.schema.string(),
Keep runtime packages in dependencies
The production image installs dependencies only, so every package your server imports at runtime belongs there. The Hypermedia starter kit imports its icons in start/view.ts from a package listed in devDependencies, which works with node ace serve and stops the production server at boot with Cannot find package '@iconify-json/lucide'.
npm install --save-prod @iconify-json/lucide
In an application you already have, look at the imports of start and config the same way.
Add a health check
A health check answers only when the application can serve requests, which lets a deployment refuse a version that cannot reach its database. Create start/health.ts.
import { HealthChecks, MemoryHeapCheck } from '@adonisjs/core/health';
import { DbCheck } from '@adonisjs/lucid/database';
import db from '@adonisjs/lucid/services/db';
export const healthChecks = new HealthChecks().register([new MemoryHeapCheck(), new DbCheck(db.connection())]);
Then answer it from start/routes.ts.
import { healthChecks } from '#start/health';
router.get('/health', async ({ response }) => {
const report = await healthChecks.run();
return report.isHealthy ? response.ok(report) : response.serviceUnavailable(report);
});
/health returns 200 while PostgreSQL answers and 503 once it does not.
Add a Dockerfile
Create Dockerfile at the repository root.
FROM node:24-trixie-slim AS base
WORKDIR /app
FROM base AS build
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN node ace build
FROM base AS production-dependencies
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
FROM base AS runtime
ENV NODE_ENV=production \
HOST=0.0.0.0 \
PORT=8080 \
LOG_LEVEL=info \
SESSION_DRIVER=cookie \
TZ=UTC
COPY --from=production-dependencies /app/node_modules ./node_modules
COPY --from=build /app/build ./
USER node
EXPOSE 8080
CMD ["node", "bin/server.js"]
node ace build compiles the TypeScript and the Vite assets into build, a complete application with its own package.json, so the runtime stage copies it to /app and adds the production node_modules beside it. The build stops on a type error, which keeps a broken version out of production.
The ENV line gives the values start/env.ts requires that are not secret. HOST=0.0.0.0 replaces the localhost of .env.example, because a server bound to localhost gets no request from outside its container. The CMD starts node rather than npm start, so the SIGTERM handler of bin/server.ts receives the signal and the server stops in well under a second.
The image holds no .env file, and AdonisJS then reads its configuration from the environment alone.
Exclude local files from the image
Create .dockerignore beside the Dockerfile.
.git
node_modules
build
tmp
public/assets
.env
.env.*
It keeps your local .env, with its APP_KEY, out of the image, along with your local build and dependencies.
Set APP_KEY and APP_URL
AdonisJS needs two values that belong to each installation.
APP_KEYsigns and encrypts cookies, sessions and the values you encrypt with theencryptionservice. Create it once withnode ace generate:key --showand keep it, because a new key logs out every user and leaves encrypted values unreadable.APP_URLis the public address of the application, such ashttps://app.example.com, which signed URLs and the links in your emails use.
Without them, the server stops at boot with Missing environment variable "APP_KEY".
Run the migrations before traffic
Run the migrations once for each deployment, before the new version serves anyone.
node ace migration:run --force
--force is not optional. In production, migration:run asks for a confirmation, and without a terminal it takes the answer as no and exits with code 0 having migrated nothing, so a deploy script without the flag reports success and starts your new code on the old schema.
Keep the migrations out of the CMD, where each replica would run them as it starts and a failed migration would leave the containers restarting. The previous version still serves while the migrations run, so add columns and tables in one deployment and remove the old ones in a later one.
Test the image locally
Try the image with a PostgreSQL container, using the key from node ace generate:key --show.
docker build -t hello-adonis .
docker network create hello-adonis
docker run -d --name hello-adonis-db --network hello-adonis \
-e POSTGRES_PASSWORD=secret -e POSTGRES_DB=app postgres:18
cat > .env.docker <<'EOF'
APP_KEY=...
APP_URL=http://localhost:8080
DB_HOST=hello-adonis-db
DB_PORT=5432
DB_USER=postgres
DB_PASSWORD=secret
DB_DATABASE=app
EOF
docker run --rm --network hello-adonis --env-file .env.docker hello-adonis node ace migration:run --force
docker run --rm --name hello-adonis --network hello-adonis -p 8080:8080 --env-file .env.docker hello-adonis
Give PostgreSQL a few seconds to start before the migrations. Visit http://localhost:8080 and http://localhost:8080/health, then remove the containers and the network. Keep .env.docker out of Git, since it holds your test secrets.
Publish the image from your CI
Build the image in your CI on each push and publish it to a container registry, so your servers spend nothing on builds and your code can stay private. Build an image in GitHub Actions and publish it to GHCR gives a complete workflow for this Dockerfile, built for your cluster's CPU architecture, which publishes an image such as ghcr.io/acme/hello-adonis:main on each push to main.
Deploy it with Tenkai
- Create the database. Create a PostgreSQL managed service and wait until it is running.
- Create the application. Choose Add resource, then Application, Web application and Container image. Enter the image reference with its moving tag, such as
ghcr.io/acme/hello-adonis:main, and for a private package choose a GHCR credential that Can pull it, as pull a private GHCR image explains. - Add the variables. In the Variables tab, add
APP_KEYas a Sensitive variable andAPP_URLwith the address of the default route, or of your custom domain. - Bind the database. Bind the PostgreSQL service to the application, choose Fill from a format, then Separate variables, which gives exactly the
DB_HOST,DB_PORT,DB_USER,DB_PASSWORDandDB_DATABASEyour configuration reads. - Set the commands. Choose More actions, then Change configuration…, and enter
node ace migration:run --forceunder Pre-deploy commands. - Add the health check. In the Configuration tab, add a health check on
/health.
Tenkai runs the pre-deploy command in a container of the new image, then starts the new version beside the running one, waits for /health to answer 200 and moves the traffic.
Each push then publishes a new image, and a last step of your workflow can deploy it by calling the application's deploy hook.
Deploy and verify
Choose Deploy, and once the release is active, open its default route over HTTPS. When something fails, the deployment Details and the Logs tab name the cause.
Missing environment variablenames the variable to add, then deploy again.Cannot find packagenames a package to move fromdevDependenciestodependencies.- A pre-deploy command that fails with
ECONNREFUSEDreaches no database atDB_HOSTandDB_PORT, so check that the PostgreSQL service is running and bound. - A health check that answers
503shows which check failed in its JSON.
Once it works, connect your own domain and update APP_URL to match.
Queues and scheduled commands
Files written to the container disappear with each deployment, so keep uploads on an S3-compatible bucket with Drive, such as a bucket on your cluster. To run a queue consumer, create a worker from the same repository with the node ace command that starts it as its entrypoint, and run a recurring command as a scheduled job. Give both the same variables and binding as the web application.