Prepare your app
The main.ts that nest new generates already reads PORT, and we set it for you. With no host argument, Node listens on every interface, so you don't need to change the call. A production-ready version:
import { NestFactory } from '@nestjs/core';
import { NestExpressApplication } from '@nestjs/platform-express';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create<NestExpressApplication>(AppModule);
app.set('trust proxy', 1);
app.enableShutdownHooks();
await app.listen(process.env.PORT ?? 3000);
}
void bootstrap();On the Fastify adapter, pass the host. Fastify listens only on localhost by default, and the health check can't reach it there:
await app.listen(process.env.PORT ?? 3000, '0.0.0.0');
The build runs npm run build, which is nest build and writes dist/. Dev dependencies are installed and kept, so @nestjs/cli can stay a dev dependency. Then set the Start command to node dist/main. If you keep a .ts file in the project root, Nest outputs dist/src/main.js instead, so start that path.
We build on Node 22 unless engines.node in package.json asks for another version. app.enableShutdownHooks() matters here: Nest's shutdown hooks are off by default, and the old container gets a SIGTERM when a new deploy goes live or the app goes to sleep.
Environment variables and @nestjs/config
Add variables in the project's settings. They're available both while the app builds and while it runs, so build-time values (like framework-public keys) work without extra setup. See Environment variables.
With @nestjs/config, load it once in the root module:
import { ConfigModule } from '@nestjs/config';
@Module({
imports: [ConfigModule.forRoot({ isGlobal: true })],
})
export class AppModule {}Variables set in Settings take precedence over a .env file, so a local .env never overrides production values. Keep .env out of git anyway, and set ignoreEnvFile: true if you want production to read only the real environment.
Proxy and CORS
HTTPS ends at the platform's proxy, so your app sees plain HTTP. app.set('trust proxy', 1) in the main.ts above makes Express read the forwarded headers, so secure cookies are sent and @nestjs/throttler sees the real client IP.
If a frontend on another domain calls the API, enable CORS for that origin:
app.enableCors({ origin: process.env.FRONTEND_URL });Health check
Every deploy requests your health check path (/ by default) and only goes live once it returns a 2xx or 3xx status. The platform keeps checking it after that. If / returns 404 or needs a login, add a route like /healthz that returns 200 and set it as the Health Check Path in Settings. Otherwise the deploy fails and the previous version keeps serving.
The generated AppController answers GET / with 200, so a new project passes as is. Once you add a global prefix like app.setGlobalPrefix('api'), / returns 404. Add a health route, keep it out of the prefix, and set /health as the Health Check Path:
app.setGlobalPrefix('api', { exclude: ['health'] });A plain @Get() that returns "ok" is enough. For a check that also pings the database, use @nestjs/terminus:
@Controller('health')
export class HealthController {
constructor(
private health: HealthCheckService,
private db: TypeOrmHealthIndicator,
) {}
@Get()
@HealthCheck()
check() {
return this.health.check([
() => this.db.pingCheck('database').withTimeout(1000),
]);
}
}Terminus answers with an error status when a check fails, so a deploy whose database is unreachable never goes live.
Add a database
Add a Postgres addon and DATABASE_URL is injected automatically (a Redis addon injects REDIS_URL). The database is private to the project, so it isn't reachable while the app builds, only once the container runs. Run migrations from the Start command, not the Build command.
TypeORM
TypeOrmModule.forRoot({
type: 'postgres',
url: process.env.DATABASE_URL,
autoLoadEntities: true,
synchronize: false,
migrations: ['dist/migrations/*.js'],
migrationsRun: true,
})Keep synchronize off in production: it changes the schema on every start and can drop data. migrationsRun: true applies pending migrations each time the app starts.
Prisma
Prisma 7 needs a driver adapter. For Postgres, pass DATABASE_URL to @prisma/adapter-pg:
import { PrismaPg } from '@prisma/adapter-pg';
import { PrismaClient } from '../generated/prisma/client';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit {
constructor() {
super({ adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }) });
}
async onModuleInit() {
await this.$connect();
}
}The generated client isn't committed, so generate it before the build and apply migrations before the server starts. In Settings → Build:
Build command: npx prisma generate && npm run build Start command: npx prisma migrate deploy && node dist/main
Deploy a NestJS app for free
The Free plan costs nothing and needs no card. It includes:
- One project, live over HTTPS at
<project-slug>.ethiodeploy.app. - 256 MB of memory and half a CPU core.
- A managed Postgres, MySQL or Redis addon with 512 MB of storage. It doesn't expire.
- A redeploy on every push, the same as the paid plans.
The catch is sleep. After 30 minutes without traffic the app stops, and the next request starts it again. We hold that request for up to 30 seconds while the app boots, so the first visitor after a quiet spell waits a little.
A compiled Nest API runs as one Node process and fits a small app in 256 MB. Start it with node dist/main, as above, so a wake from sleep runs your code straight away instead of compiling it first. Sleeping stops the process, so @nestjs/schedule cron jobs and in-memory caches stop with it. Keep cached data in the Redis addon and anything durable in Postgres.
When the app has to stay awake or needs your own domain, Pro is $10 or 1,399 ETB a month: always on, 1 GB of memory and a 5 GB database.
Deploy
- Sign in with GitHub and install the EthioDeploy app on the repo.
- Click “New Project” and pick the repo.
- Watch the build in the dashboard. When the health check passes, your app is live at
<project-slug>.ethiodeploy.app. Every push to the default branch redeploys, and the old version keeps serving until the new one is healthy.
Troubleshooting
Every deploy and wake starts with a TypeScript compile
Cause: No Start command is set, so npm start runs nest start, which builds before running.
Fix: Set the Start command to node dist/main.
Cannot find module '/app/dist/main'
Cause: A .ts file in the project root moved the entry point to dist/src/main.js.
Fix: Start node dist/src/main, or move the file out of the root.
Deploy fails: GET / returned 404
Cause: A global prefix moved every route, or the root controller was removed.
Fix: Add a /health route excluded from the prefix and set it as the Health Check Path.
prisma migrate deploy fails in the build with a connection error
Cause: The database addon is only reachable from the running container, not from the build.
Fix: Move it to the Start command: npx prisma migrate deploy && node dist/main.
Cannot find module '../generated/prisma/client'
Cause: The Prisma client is generated, not committed, and nothing generated it.
Fix: Set the Build command to npx prisma generate && npm run build.
Still stuck? Check the deployment's build log in the dashboard, or see Troubleshooting.
Questions
Yes. The Free plan runs one project with a 512 MB Postgres or Redis addon at no cost. The app sleeps after 30 minutes without traffic and wakes on the next request, and the container has 256 MB of memory. Pro ($10/month) keeps it always on with 1 GB.
No. We detect package.json, install from your lockfile and run npm run build. Set the Start command to node dist/main in Settings → Build so the app starts from the compiled output.
Yes. Fastify listens only on localhost by default, so call app.listen(process.env.PORT ?? 3000, '0.0.0.0').
Run them when the container starts. With Prisma, set the Start command to npx prisma migrate deploy && node dist/main. With TypeORM, set migrationsRun: true. The database isn't reachable during the build.
Ready to deploy?
Sign in with GitHub and pick your repo. No Dockerfile, no YAML.
More guides
All guides on the deploy guides page. Custom domains, env vars, addons and billing are in the docs.