Chapter 20 · Azure Kubernetes App Deploy
Subchapter 20.28
knowledge-packs/frameworks/express.mdMarkdown7 KBView on GitHub
Applies to: Projects detected with
package.jsoncontainingexpressorfastifyas a dependency
| Property | Value |
|---|---|
| Signal files | package.json containing express or fastify |
| Default port | 3000 |
| Health path | /healthz |
| Base template | templates/dockerfiles/node.Dockerfile (+ references/base-images.md) |
Node.js does not handle SIGTERM correctly when running as PID 1. The base template includes dumb-init as the entrypoint to forward signals properly; no application-level changes are needed unless the app registers explicit cleanup handlers.
Fastify defaults to listening on 127.0.0.1, which is unreachable from outside the container. Bind to 0.0.0.0 explicitly:
await fastify.listen({ port: 3000, host: '0.0.0.0' });If the pod starts but health probes fail with connection refused, this is almost always the cause. Express already binds to 0.0.0.0 by default — no change needed for Express apps.
| Package Manager | Install (all) | Install (prod only) | Lock File |
|---|---|---|---|
| npm | npm ci | npm ci --omit=dev | package-lock.json |
| yarn | yarn install --frozen-lockfile | yarn install --frozen-lockfile --production | yarn.lock |
| pnpm | pnpm install --frozen-lockfile | pnpm install --frozen-lockfile --prod | pnpm-lock.yaml |
Copy the correct lock file in the Dockerfile COPY step to match the project’s package manager.
Node.js frameworks do not provide health endpoints out of the box. Add a /healthz route manually.
app.get('/healthz', (req, res) => {
res.status(200).json({ status: 'UP' });
});fastify.get('/healthz', async () => {
return { status: 'UP' };
});For richer checks (database connectivity, downstream services), extend the handler to verify dependencies and return 503 when unhealthy.
livenessProbe:
httpGet:
path: /healthz
port: 3000
initialDelaySeconds: 5
periodSeconds: 15
timeoutSeconds: 3
failureThreshold: 3
readinessProbe:
httpGet:
path: /healthz
port: 3000
initialDelaySeconds: 5
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 3Note: Node.js apps start in under a second, so initialDelaySeconds: 5 is sufficient. No startupProbe is needed unless the app performs heavy initialization (e.g., loading ML models).
Node.js projects use a variety of database libraries. The standard pattern is a DATABASE_URL connection string injected via environment variable:
| Library | Connection Pattern | Config Property |
|---|---|---|
pg (node-postgres) | new Pool({ connectionString: process.env.DATABASE_URL }) | DATABASE_URL |
| Prisma | datasource db { url = env("DATABASE_URL") } in schema.prisma | DATABASE_URL |
| Sequelize | new Sequelize(process.env.DATABASE_URL) | DATABASE_URL |
| Knex | connection: process.env.DATABASE_URL in knexfile.js | DATABASE_URL |
env:
- name: DATABASE_URL
value: "postgresql://{{IDENTITY_NAME}}@{{PG_SERVER_NAME}}.postgres.database.azure.com:5432/{{DB_NAME}}?sslmode=require"
- name: PGHOST
value: "{{PG_SERVER_NAME}}.postgres.database.azure.com"
- name: PGDATABASE
value: "{{DB_NAME}}"
- name: PGUSER
value: "{{IDENTITY_NAME}}"
- name: PGPORT
value: "5432"
- name: PGSSLMODE
value: "require"For Workload Identity with passwordless authentication, use the @azure/identity package with pg to obtain Azure AD tokens instead of passwords.
When readOnlyRootFilesystem: true is set, Node.js apps need only /tmp writable:
multer, @fastify/multipart) stage files to /tmp/tmpvolumes:
- name: tmp
emptyDir: {}
containers:
- name: app
volumeMounts:
- name: tmp
mountPath: /tmpNode.js is single-threaded and relatively lightweight. These are starting-point defaults — tune based on observed usage.
| Resource | Request | Limit |
|---|---|---|
| CPU | 100m | 500m |
| Memory | 128Mi | 256Mi |
For memory-intensive workloads (large payloads, SSR), increase the memory limit and set --max-old-space-size to ~75% of the limit.
PORT=3000app.listen(process.env.PORT || 3000)Express binds to 0.0.0.0 by default, so it is reachable from outside the container without additional configuration.
Fastify binds to 127.0.0.1 by default — you must pass host: '0.0.0.0' in the listen() call or the pod will start but all probes and traffic will fail with connection refused.
| Scenario | Build Command | Output | Entrypoint |
|---|---|---|---|
| TypeScript | npm run build (invokes tsc) | dist/ | node dist/index.js |
| JavaScript (no build) | None | src/ | node src/index.js |
| Bundler (esbuild/webpack) | npm run build | dist/bundle.js | node dist/bundle.js |
For TypeScript projects, ensure tsconfig.json has "outDir": "dist" and the Dockerfile copies the dist/ folder to the runtime stage. Do not install typescript or ts-node in the production image.
| Issue | Symptom | Fix |
|---|---|---|
| No SIGTERM handling | Pod takes 30s to terminate (killed by SIGKILL after grace period) | Use dumb-init as entrypoint, or add explicit process.on('SIGTERM', ...) handler to close the server gracefully |
| ECONNRESET on PostgreSQL | Error: Connection terminated unexpectedly | Configure pool idleTimeoutMillis and connectionTimeoutMillis; Azure PG Flexible Server closes idle connections after ~5 min |
| Fastify localhost binding | Health probes fail with connection refused despite app running | Pass host: '0.0.0.0' to fastify.listen() — Fastify defaults to 127.0.0.1 |
| node_modules bloat | Image > 500MB, slow pulls from ACR | Run npm ci --omit=dev in a separate stage; consider esbuild bundling for single-file output |
| Memory leak under load | Pod OOMKilled after hours of traffic | Set --max-old-space-size to ~75% of container memory limit (e.g., --max-old-space-size=384 for 512Mi limit); profile with --inspect locally |