Deploy a Node.js TypeScript app to App Service on Linux without publishing passwords
Run a TypeScript HTTP server on App Service for Linux — a plan and web app in Bicep (Microsoft.Web 2025-03-01) with a user-assigned identity, Node 22 or 24, PM2 in the foreground, a health check path, TLS 1.2, FTP off and basic publishing credentials disabled — a server that listens on PORT and answers health checks, a zip built locally, and deployment with az webapp deploy under your Entra sign-in.
markdownguidev1QTDZ-GN4Rstage: buildtrust: officialservice: app-serviceinterface: iacinterface: clilanguages: typescriptlanguages: bicepdeploy Node.js App Service LinuxApp Service BicepMicrosoft.Web/sites@2025-03-01 applinuxlinuxFxVersion NODE|22-ltsNODE|24-ltsappCommandLine pm2 --no-daemonhealthCheckPath BicepbasicPublishingCredentialsPolicies scm ftp allow falseftpsState DisabledkeyVaultReferenceIdentity BicepAZURE_CLIENT_ID App Serviceaz webapp deploy --type zipSCM_DO_BUILD_DURING_DEPLOYMENT falseprocess.env.PORT TypeScriptWEBSITE_INSTANCE_ID
Deploy a Node.js TypeScript app to App Service on Linux
Microsoft.Web 2025-03-01 is the newest API version with Bicep 0.48.1 types (later ones give BCP081). Check the runtime strings live with az webapp list-runtimes --os-type linux.
1. Infrastructure (main.bicep)
@description('Web app name: globally unique.')
param appName string
param location string = resourceGroup().location
@description('App Service plan SKU, for example B1, P0v3 or P1v3.')
param skuName string = 'P0v3'
@allowed(['NODE|22-lts', 'NODE|24-lts'])
param linuxFxVersion string = 'NODE|22-lts'
resource identity 'Microsoft.ManagedIdentity/userAssignedIdentities@2024-11-30' = {
name: 'id-${appName}'
location: location
}
resource plan 'Microsoft.Web/serverfarms@2025-03-01' = {
name: 'plan-${appName}'
location: location
kind: 'linux'
sku: {
name: skuName
}
properties: {
reserved: true
}
}
resource app 'Microsoft.Web/sites@2025-03-01' = {
name: appName
location: location
kind: 'app,linux'
identity: {
type: 'UserAssigned'
userAssignedIdentities: {
'${identity.id}': {}
}
}
properties: {
serverFarmId: plan.id
httpsOnly: true
keyVaultReferenceIdentity: identity.id
siteConfig: {
linuxFxVersion: linuxFxVersion
appCommandLine: 'pm2 start dist/server.js --no-daemon'
healthCheckPath: '/healthz'
minTlsVersion: '1.2'
ftpsState: 'Disabled'
alwaysOn: true
appSettings: [
{
name: 'AZURE_CLIENT_ID'
value: identity.properties.clientId
}
{
name: 'SCM_DO_BUILD_DURING_DEPLOYMENT'
value: 'false'
}
]
}
}
}
// Turn off basic (user name and password) publishing for SCM and FTP
resource scmBasicAuth 'Microsoft.Web/sites/basicPublishingCredentialsPolicies@2025-03-01' = {
parent: app
name: 'scm'
properties: {
allow: false
}
}
resource ftpBasicAuth 'Microsoft.Web/sites/basicPublishingCredentialsPolicies@2025-03-01' = {
parent: app
name: 'ftp'
properties: {
allow: false
}
}
output appUrl string = 'https://${app.properties.defaultHostName}'
output principalId string = identity.properties.principalId
Deploy: az deployment group create -g <rg> -f main.bicep -p appName=<app>. Grant the identity's principalId the data roles it needs (guides/rbac/assign-roles.md); AZURE_CLIENT_ID makes DefaultAzureCredential pick this identity. alwaysOn needs Basic or higher.
2. The server (src/server.ts)
import { createServer } from "node:http";
// App Service sets PORT; 8080 is only a local default.
const port = Number(process.env.PORT ?? 8080);
const server = createServer((req, res) => {
if (req.url === "/healthz") {
// Health check: answer fast, no auth, no calls to dependencies.
res.writeHead(200, { "content-type": "text/plain" }).end("ok");
return;
}
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify({ message: "hello", instance: process.env.WEBSITE_INSTANCE_ID ?? "local" }));
});
server.listen(port, () => console.log(`listening on ${port}`));
// Close cleanly when the container is stopped.
process.on("SIGTERM", () => server.close(() => process.exit(0)));
3. Build and deploy
npm ci && npm run build # tsc → dist/server.js
npm prune --omit=dev # ship production dependencies only
zip -r app.zip dist node_modules package.json
az webapp deploy -g <rg> -n <app> --src-path app.zip --type zip
Build automation is off (SCM_DO_BUILD_DURING_DEPLOYMENT=false), so the zip is run as-is: it must contain dist and node_modules. To build on the platform instead, set it to true and zip the sources; see reference/app-service/configuration-and-networking.md for what that build runs. az webapp deploy authenticates with your Entra sign-in, so it works with basic publishing credentials disabled; publish profiles and FTP don't.
4. Check it
az webapp log tail -g <rg> -n <app>
curl -sS https://<app>.azurewebsites.net/healthz
If the container never becomes ready, the cause is almost always the port, the startup command or a missing file in the zip: reference/app-service/errors.md. For CI, use GitHub Actions with OIDC: patterns/github-actions-to-azure.md. For traces and metrics: guides/monitor/instrument-node-app-with-opentelemetry.md.