This guide deploys a small HTTP container on Azure Container Apps. It builds the image from the included app/ directory, pushes it to Azure Container Registry, stores one example setting in Container Apps secrets, and exports a public URL after the service is ready.
Architecture
The stack creates four pieces:
- A local container image built from
app/Dockerfile. - A private image repository in Azure Container Registry.
- A managed secret that becomes the
APP_MESSAGEenvironment variable. - A public Azure Container Apps service with an HTTP liveness probe on
/healthand Container Apps HTTP scaling rules.
The app listens on port 80, returns JSON from /, and returns ok from /health.
Azure Container Apps is the right choice when you want a container endpoint with managed revisions, external ingress, scale rules, and registry integration without managing AKS. This variant creates the resource group, ACR registry, Log Analytics workspace, managed environment, Container App, registry secret, app secret, probe, and HTTP scale rule.
Prerequisites
Before you start, install:
- a Pulumi account and the Pulumi CLI
- Docker running locally so Pulumi can build and push the container image
- Node.js 20 or newer and npm
- an Azure subscription where you can create resource groups, Container Registry, Log Analytics, Container Apps environments, and Container Apps
What you get in the download
The downloadable example zip includes:
index.tsas the Pulumi entrypointcomponents/container-service.tsas the reusable Azure Container Apps componentapp/with a tiny HTTP service and Dockerfilepackage.jsonandtsconfig.jsonfor the Pulumi project
__main__.pyas the Pulumi entrypointcomponents/container_service.pyas the reusable Azure Container Apps componentapp/with a tiny HTTP service and Dockerfilerequirements.txtfor the Pulumi project
main.goas the Pulumi entrypointcontainerservice/service.goas the reusable Azure Container Apps componentapp/with a tiny HTTP service and Dockerfilego.modfor the Pulumi project
Quickstart
Start from the downloaded example and run these commands from the project root.
pulumi stack init dev
pulumi config set azure-native:location eastus
pulumi config set --secret appMessage 'hello from Container Apps secrets'
Install language dependencies:
npm install
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
go mod tidy
Then deploy:
pulumi up
App shape
The app is small so you can focus on the infrastructure first. app/server.js exposes /health for an HTTP liveness probe on /health and / for a JSON response that includes the secret-backed APP_MESSAGE. Replace the app/ directory with your own container when the first deployment works.
Application code
app/server.js
The container runs this tiny Node.js HTTP server. It returns JSON from /, echoes the APP_MESSAGE secret value, and answers the platform health check at /health. The same app/ directory ships in every language starter.
const http = require("http");
const port = Number(process.env.PORT || 80);
const message = process.env.APP_MESSAGE || "hello from Pulumi";
const server = http.createServer((req, res) => {
if (req.url === "/health") {
res.writeHead(200, { "content-type": "text/plain" });
res.end("ok");
return;
}
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify({ message, path: req.url }));
});
server.listen(port, "0.0.0.0", () => {
console.log(`listening on ${port}`);
});
Deploy and iterate
Run pulumi up whenever you change the Pulumi program or the app image. The image tag is stack-scoped, so a new build updates the running replica definition and rolls the service forward through Azure Container Apps.
Outputs
After deployment, the stack exports:
url- the Container Apps fully qualified domain name over HTTPSimageName- the pushed image reference in Azure Container RegistrysecretName- the managed secret that backsAPP_MESSAGE
Open url in a browser or run curl $(pulumi stack output url)/health to verify the service.
Operations and cleanup
Use az containerapp show, Container Apps revision status, and Log Analytics logs to inspect rollout and probe failures.
Consumption workload profiles scale replicas down to the configured minimum. Registry storage and Log Analytics ingestion can still create small charges.
Destroy everything with:
pulumi destroy
pulumi stack rm
Blueprint Pulumi program
The entrypoint reads stack config, creates the reusable container service component, then exports the URL and registry coordinates produced by Azure Container Apps.
import * as pulumi from "@pulumi/pulumi";
import { ServerlessContainerService } from "./components/container-service";
const config = new pulumi.Config();
const service = new ServerlessContainerService("app", {
appPath: "./app",
appMessage: config.requireSecret("appMessage"),
containerPort: 80,
minReplicas: config.getNumber("minReplicas") ?? 1,
maxReplicas: config.getNumber("maxReplicas") ?? 3,
});
export const url = service.url;
export const imageName = service.imageName;
export const secretName = service.secretName;
import pulumi
from components.container_service import ServerlessContainerService
config = pulumi.Config()
service = ServerlessContainerService(
"app",
app_path="./app",
app_message=config.require_secret("appMessage"),
container_port=80,
min_replicas=config.get_int("minReplicas") or 1,
max_replicas=config.get_int("maxReplicas") or 3,
)
pulumi.export("url", service.url)
pulumi.export("imageName", service.image_name)
pulumi.export("secretName", service.secret_name)
package main
import (
"fmt"
"github.com/pulumi/pulumi/sdk/v3/go/pulumi"
"github.com/pulumi/pulumi/sdk/v3/go/pulumi/config"
"serverless-containers/containerservice"
)
func Program(ctx *pulumi.Context) error {
cfg := config.New(ctx, "")
appMessage := cfg.RequireSecret("appMessage")
minUnits := cfg.GetInt("minReplicas")
if minUnits == 0 {
minUnits = 1
}
maxUnits := cfg.GetInt("maxReplicas")
if maxUnits == 0 {
maxUnits = 3
}
service, err := containerservice.NewServerlessContainerService(ctx, "app", &containerservice.ServerlessContainerServiceArgs{
AppPath: "./app",
AppMessage: appMessage,
ContainerPort: 80,
MinReplicas: minUnits,
MaxReplicas: maxUnits,
})
if err != nil {
return fmt.Errorf("create service: %w", err)
}
ctx.Export("url", service.URL)
ctx.Export("imageName", service.ImageName)
ctx.Export("secretName", service.SecretName)
return nil
}
func main() {
pulumi.Run(Program)
}
Reusable component
The component owns the registry, image build, serverless container service, health check, secret injection, and autoscaling settings for Azure Container Apps. The downloadable starter includes this same file.
import * as pulumi from "@pulumi/pulumi";
import * as resources from "@pulumi/azure-native/resources";
import * as containerregistry from "@pulumi/azure-native/containerregistry";
import * as operationalinsights from "@pulumi/azure-native/operationalinsights";
import * as app from "@pulumi/azure-native/app";
import * as docker from "@pulumi/docker";
export interface ServerlessContainerServiceArgs { appPath: string; appMessage: pulumi.Input<string>; containerPort: number; minReplicas: number; maxReplicas: number; }
export class ServerlessContainerService extends pulumi.ComponentResource {
public readonly url: pulumi.Output<string>; public readonly imageName: pulumi.Output<string>; public readonly secretName: pulumi.Output<string>;
constructor(name: string, args: ServerlessContainerServiceArgs, opts?: pulumi.ComponentResourceOptions) {
super("guides:serverless:AzureContainerApp", name, {}, opts);
const resourceGroup = new resources.ResourceGroup(`${name}-rg`, {}, { parent: this });
const registry = new containerregistry.Registry(`${name}acr`, { resourceGroupName: resourceGroup.name, sku: { name: "Basic" }, adminUserEnabled: true }, { parent: this });
const creds = containerregistry.listRegistryCredentialsOutput({ resourceGroupName: resourceGroup.name, registryName: registry.name });
const registryUsername = pulumi.output(creds.username).apply(username => username!);
const registryPassword = pulumi.output(creds.passwords).apply(passwords => passwords![0].value!);
const image = new docker.Image(`${name}-image`, { build: { context: args.appPath, platform: "linux/amd64" }, imageName: pulumi.interpolate`${registry.loginServer}/app:latest`, registry: { server: registry.loginServer, username: registryUsername, password: registryPassword } }, { parent: this });
const workspace = new operationalinsights.Workspace(`${name}-logs`, { resourceGroupName: resourceGroup.name, sku: { name: "PerGB2018" }, retentionInDays: 30 }, { parent: this });
const keys = operationalinsights.getSharedKeysOutput({ resourceGroupName: resourceGroup.name, workspaceName: workspace.name });
const environment = new app.ManagedEnvironment(`${name}-env`, { resourceGroupName: resourceGroup.name, appLogsConfiguration: { destination: "log-analytics", logAnalyticsConfiguration: { customerId: workspace.customerId.apply(customerId => customerId!), sharedKey: pulumi.output(keys.primarySharedKey).apply(primarySharedKey => primarySharedKey!) } } }, { parent: this });
const containerApp = new app.ContainerApp(`${name}-app`, {
resourceGroupName: resourceGroup.name,
managedEnvironmentId: environment.id,
configuration: { activeRevisionsMode: "Single", ingress: { external: true, targetPort: args.containerPort }, registries: [{ server: registry.loginServer, username: registryUsername, passwordSecretRef: "registry-password" }], secrets: [{ name: "registry-password", value: registryPassword }, { name: "app-message", value: args.appMessage }] },
template: { scale: { minReplicas: args.minReplicas, maxReplicas: args.maxReplicas, rules: [{ name: "http", http: { metadata: { concurrentRequests: "50" } } }] }, containers: [{ name: "app", image: image.imageName, env: [{ name: "APP_MESSAGE", secretRef: "app-message" }], probes: [{ type: "Liveness", httpGet: { path: "/health", port: args.containerPort }, initialDelaySeconds: 10, periodSeconds: 10 }] }] },
}, { parent: this });
this.url = pulumi.interpolate`https://${containerApp.configuration.apply(c => c?.ingress?.fqdn ?? "")}`;
this.imageName = image.imageName; this.secretName = pulumi.output("app-message");
this.registerOutputs({ url: this.url, imageName: this.imageName, secretName: this.secretName });
}
}
import pulumi
import pulumi_azure_native.app as app
import pulumi_azure_native.containerregistry as acr
import pulumi_azure_native.operationalinsights as insights
import pulumi_azure_native.resources as resources
import pulumi_docker as docker
class ServerlessContainerService(pulumi.ComponentResource):
def __init__(self, name, app_path, app_message, container_port, min_replicas, max_replicas, opts=None):
super().__init__("guides:serverless:AzureContainerApp", name, None, opts)
child = pulumi.ResourceOptions(parent=self)
group = resources.ResourceGroup(f"{name}-rg", opts=child)
registry = acr.Registry(f"{name}acr", resource_group_name=group.name, sku=acr.SkuArgs(name="Basic"), admin_user_enabled=True, opts=child)
creds = acr.list_registry_credentials_output(resource_group_name=group.name, registry_name=registry.name)
image = docker.Image(f"{name}-image", build=docker.DockerBuildArgs(context=app_path, platform="linux/amd64"), image_name=pulumi.Output.concat(registry.login_server, "/app:latest"), registry=docker.RegistryArgs(server=registry.login_server, username=creds.username, password=creds.passwords[0].value), opts=child)
workspace = insights.Workspace(f"{name}-logs", resource_group_name=group.name, sku=insights.WorkspaceSkuArgs(name="PerGB2018"), retention_in_days=30, opts=child)
keys = insights.get_shared_keys_output(resource_group_name=group.name, workspace_name=workspace.name)
environment = app.ManagedEnvironment(f"{name}-env", resource_group_name=group.name, app_logs_configuration=app.AppLogsConfigurationArgs(destination="log-analytics", log_analytics_configuration=app.LogAnalyticsConfigurationArgs(customer_id=workspace.customer_id, shared_key=keys.primary_shared_key)), opts=child)
container_app = app.ContainerApp(f"{name}-app", resource_group_name=group.name, managed_environment_id=environment.id, configuration=app.ConfigurationArgs(active_revisions_mode="Single", ingress=app.IngressArgs(external=True, target_port=container_port), registries=[app.RegistryCredentialsArgs(server=registry.login_server, username=creds.username, password_secret_ref="registry-password")], secrets=[app.SecretArgs(name="registry-password", value=creds.passwords[0].value), app.SecretArgs(name="app-message", value=app_message)]), template=app.TemplateArgs(scale=app.ScaleArgs(min_replicas=min_replicas, max_replicas=max_replicas, rules=[app.ScaleRuleArgs(name="http", http=app.HttpScaleRuleArgs(metadata={"concurrentRequests":"50"}))]), containers=[app.ContainerArgs(name="app", image=image.image_name, env=[app.EnvironmentVarArgs(name="APP_MESSAGE", secret_ref="app-message")], probes=[app.ContainerAppProbeArgs(type="Liveness", http_get=app.ContainerAppProbeHttpGetArgs(path="/health", port=container_port), initial_delay_seconds=10, period_seconds=10)])]), opts=child)
self.url = container_app.configuration.apply(lambda c: f"https://{c.ingress.fqdn}")
self.image_name = image.image_name
self.secret_name = pulumi.Output.from_input("app-message")
self.register_outputs({"url": self.url, "imageName": self.image_name, "secretName": self.secret_name})
package containerservice
import (
app "github.com/pulumi/pulumi-azure-native-sdk/app/v3"
acr "github.com/pulumi/pulumi-azure-native-sdk/containerregistry/v3"
insights "github.com/pulumi/pulumi-azure-native-sdk/operationalinsights/v3"
resources "github.com/pulumi/pulumi-azure-native-sdk/resources/v3"
"github.com/pulumi/pulumi-docker/sdk/v4/go/docker"
"github.com/pulumi/pulumi/sdk/v3/go/pulumi"
)
type ServerlessContainerServiceArgs struct {
AppPath string
AppMessage pulumi.StringOutput
ContainerPort int
MinReplicas int
MaxReplicas int
}
type ServerlessContainerService struct {
pulumi.ResourceState
URL pulumi.StringOutput
ImageName pulumi.StringOutput
SecretName pulumi.StringOutput
}
func NewServerlessContainerService(ctx *pulumi.Context, name string, args *ServerlessContainerServiceArgs, opts ...pulumi.ResourceOption) (*ServerlessContainerService, error) {
component := &ServerlessContainerService{}
if err := ctx.RegisterComponentResource("guides:serverless:AzureContainerApp", name, component, opts...); err != nil {
return nil, err
}
child := pulumi.Parent(component)
group, err := resources.NewResourceGroup(ctx, name+"-rg", nil, child)
if err != nil {
return nil, err
}
registry, err := acr.NewRegistry(ctx, name+"acr", &acr.RegistryArgs{ResourceGroupName: group.Name, Sku: &acr.SkuArgs{Name: pulumi.String("Basic")}, AdminUserEnabled: pulumi.Bool(true)}, child)
if err != nil {
return nil, err
}
creds := acr.ListRegistryCredentialsOutput(ctx, acr.ListRegistryCredentialsOutputArgs{ResourceGroupName: group.Name, RegistryName: registry.Name})
image, err := docker.NewImage(ctx, name+"-image", &docker.ImageArgs{Build: &docker.DockerBuildArgs{Context: pulumi.String(args.AppPath), Platform: pulumi.String("linux/amd64")}, ImageName: pulumi.Sprintf("%s/app:latest", registry.LoginServer), Registry: &docker.RegistryArgs{Server: registry.LoginServer, Username: creds.Username(), Password: creds.Passwords().Index(pulumi.Int(0)).Value()}}, child)
if err != nil {
return nil, err
}
workspace, err := insights.NewWorkspace(ctx, name+"-logs", &insights.WorkspaceArgs{ResourceGroupName: group.Name, Sku: &insights.WorkspaceSkuArgs{Name: pulumi.String("PerGB2018")}, RetentionInDays: pulumi.Int(30)}, child)
if err != nil {
return nil, err
}
keys := insights.GetSharedKeysOutput(ctx, insights.GetSharedKeysOutputArgs{ResourceGroupName: group.Name, WorkspaceName: workspace.Name})
env, err := app.NewManagedEnvironment(ctx, name+"-env", &app.ManagedEnvironmentArgs{ResourceGroupName: group.Name, AppLogsConfiguration: &app.AppLogsConfigurationArgs{Destination: pulumi.String("log-analytics"), LogAnalyticsConfiguration: &app.LogAnalyticsConfigurationArgs{CustomerId: workspace.CustomerId, SharedKey: keys.PrimarySharedKey()}}}, child)
if err != nil {
return nil, err
}
containerApp, err := app.NewContainerApp(ctx, name+"-app", &app.ContainerAppArgs{
ResourceGroupName: group.Name,
ManagedEnvironmentId: env.ID(),
Configuration: &app.ConfigurationArgs{
ActiveRevisionsMode: pulumi.String("Single"),
Ingress: &app.IngressArgs{
External: pulumi.Bool(true),
TargetPort: pulumi.Int(args.ContainerPort),
},
Registries: app.RegistryCredentialsArray{
&app.RegistryCredentialsArgs{
Server: registry.LoginServer,
Username: creds.Username(),
PasswordSecretRef: pulumi.String("registry-password"),
},
},
Secrets: app.SecretArray{
&app.SecretArgs{Name: pulumi.String("registry-password"), Value: creds.Passwords().Index(pulumi.Int(0)).Value()},
&app.SecretArgs{Name: pulumi.String("app-message"), Value: args.AppMessage},
},
},
Template: &app.TemplateArgs{
Scale: &app.ScaleArgs{
MinReplicas: pulumi.Int(args.MinReplicas),
MaxReplicas: pulumi.Int(args.MaxReplicas),
Rules: app.ScaleRuleArray{
&app.ScaleRuleArgs{
Name: pulumi.String("http"),
Http: &app.HttpScaleRuleArgs{Metadata: pulumi.StringMap{"concurrentRequests": pulumi.String("50")}},
},
},
},
Containers: app.ContainerArray{
&app.ContainerArgs{
Name: pulumi.String("app"),
Image: image.ImageName,
Env: app.EnvironmentVarArray{
&app.EnvironmentVarArgs{Name: pulumi.String("APP_MESSAGE"), SecretRef: pulumi.String("app-message")},
},
Probes: app.ContainerAppProbeArray{
&app.ContainerAppProbeArgs{
Type: pulumi.String("Liveness"),
HttpGet: &app.ContainerAppProbeHttpGetArgs{
Path: pulumi.String("/health"),
Port: pulumi.Int(args.ContainerPort),
},
InitialDelaySeconds: pulumi.Int(10),
PeriodSeconds: pulumi.Int(10),
},
},
},
},
},
}, child)
if err != nil {
return nil, err
}
component.URL = containerApp.Configuration.ApplyT(func(c *app.ConfigurationResponse) string { return "https://" + c.Ingress.Fqdn }).(pulumi.StringOutput)
component.ImageName = image.ImageName
component.SecretName = pulumi.String("app-message").ToStringOutput()
return component, ctx.RegisterResourceOutputs(component, pulumi.Map{"url": component.URL, "imageName": component.ImageName, "secretName": component.SecretName})
}