# How to deploy an AdonisJS application to production

Source: https://tenkai.cloud/help/deploy-adonisjs

> 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.

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](https://tenkai.cloud/help/projects-and-clusters).

## 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.

```sh
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.

```ts
// 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'`.

```sh
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`.

```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`.

```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.

```dockerfile
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.

```text
.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_KEY` signs and encrypts cookies, sessions and the values you encrypt with the `encryption` service. Create it once with `node ace generate:key --show` and keep it, because a new key logs out every user and leaves encrypted values unreadable.
- `APP_URL` is the public address of the application, such as `https://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.

```sh
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`.

```sh
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](https://tenkai.cloud/help/publish-image-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

1. **Create the database.** [Create a PostgreSQL managed service](https://tenkai.cloud/help/managed-services#create-the-service) and wait until it is running.
2. **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](https://tenkai.cloud/help/pull-private-ghcr-image) explains.
3. **Add the variables.** In the **Variables** tab, add `APP_KEY` as a **Sensitive** variable and `APP_URL` with the address of the default route, or of your custom domain.
4. **Bind the database.** [Bind the PostgreSQL service](https://tenkai.cloud/help/managed-services#bind-an-application) to the application, choose **Fill from a format**, then **Separate variables**, which gives exactly the `DB_HOST`, `DB_PORT`, `DB_USER`, `DB_PASSWORD` and `DB_DATABASE` your configuration reads.
5. **Set the commands.** Choose **More actions**, then **Change configuration…**, and enter `node ace migration:run --force` under **Pre-deploy commands**.
6. **Add the health check.** In the **Configuration** tab, add a [health check](https://tenkai.cloud/help/blue-green-deployments#the-health-check) on `/health`.

Tenkai runs the [pre-deploy command](https://tenkai.cloud/help/deployment-commands) 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](https://tenkai.cloud/help/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 variable` names the variable to add, then deploy again.
- `Cannot find package` names a package to move from `devDependencies` to `dependencies`.
- A pre-deploy command that fails with `ECONNREFUSED` reaches no database at `DB_HOST` and `DB_PORT`, so check that the PostgreSQL service is running and bound.
- A health check that answers `503` shows which check failed in its JSON.

Once it works, [connect your own domain](https://tenkai.cloud/help/custom-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](https://tenkai.cloud/help/object-storage). To run a queue consumer, create a [worker](https://tenkai.cloud/help/workers) from the same repository with the `node ace` command that starts it as its entrypoint, and run a recurring command as a [scheduled job](https://tenkai.cloud/help/scheduled-jobs). Give both the same variables and binding as the web application.
