Purpose of this file
Provision the registry, the hosting plan, and the two container web apps that run the API and UI — and configure each to reach the database, the registry, and each other.
This file declares infrastructure only — it never builds an image. The web apps are created pointing at metals-api:<image_tag> and metals-ui:<image_tag>, which may not exist yet; building and pushing them is the Deployment tutorial's job.
The full file all nine resources
terraform/app_service.tf (abridged — the UI and tutorials apps mirror the API app)resource "azurerm_container_registry" "metals" {
name = replace("${local.prefix}acr", "-", "")
resource_group_name = azurerm_resource_group.metals.name
location = azurerm_resource_group.metals.location
sku = "Basic"
admin_enabled = false
}
resource "azurerm_service_plan" "metals" {
name = "${local.prefix}-appservice-plan"
resource_group_name = azurerm_resource_group.metals.name
location = azurerm_resource_group.metals.location
os_type = "Linux"
sku_name = "B1"
}
resource "random_id" "jwt_secret" {
byte_length = 48
}
resource "azurerm_linux_web_app" "api" {
name = "${local.prefix}-api"
resource_group_name = azurerm_resource_group.metals.name
location = azurerm_service_plan.metals.location
service_plan_id = azurerm_service_plan.metals.id
https_only = true
ftp_publish_basic_authentication_enabled = false
webdeploy_publish_basic_authentication_enabled = false
identity {
type = "SystemAssigned"
}
site_config {
always_on = true
health_check_path = "/health"
health_check_eviction_time_in_min = 2
container_registry_use_managed_identity = true
application_stack {
docker_image_name = "metals-api:${var.image_tag}"
docker_registry_url = "https://${azurerm_container_registry.metals.login_server}"
}
}
logs {
detailed_error_messages = false
failed_request_tracing = false
http_logs {
file_system {
retention_in_days = 3
retention_in_mb = 100
}
}
}
app_settings = {
WEBSITES_PORT = "5000"
WEBSITES_ENABLE_APP_SERVICE_STORAGE = "false"
SCM_DO_BUILD_DURING_DEPLOYMENT = "false"
DB_HOST = azurerm_postgresql_flexible_server.metals.fqdn
DB_PORT = "5432"
DB_NAME = azurerm_postgresql_flexible_server_database.metals.name
DB_USER = local.db_user
DB_PASSWORD = var.db_password
PGSSLMODE = "require"
JWT_SECRET_KEY = random_id.jwt_secret.b64_std
JWT_EXPIRATION_MINUTES = "60"
FLASK_DEBUG = "0"
}
}
resource "azurerm_role_assignment" "api_acr_pull" {
scope = azurerm_container_registry.metals.id
role_definition_name = "AcrPull"
principal_id = azurerm_linux_web_app.api.identity[0].principal_id
principal_type = "ServicePrincipal"
}
The container registry where images live
Registry names must be globally unique across all of Azure and may contain only letters and digits — no hyphens. Since local.prefix is expeditors-<user>-metals, the replace() function strips the hyphens to produce expeditors<user>metalsacr.
Every registry can have a built-in admin account with a password. Disabling it removes that credential entirely, forcing all access through Entra ID identities and role assignments. There is consequently no registry password anywhere in this project — not in Terraform, not in GitHub, not in app settings.
The cheapest tier, with less included storage and throughput than Standard or Premium. For two small images redeployed a few times a day, it's ample — and the tier can be raised later without recreating the registry.
The App Service plan one plan, two apps
The plan is the compute you rent; the web apps are applications placed onto it. Both apps here reference the same azurerm_service_plan.metals, which has a real consequence: the UI costs nothing extra to host, because App Service bills per plan instance rather than per app.
B1 is the cheapest tier that supports always_on, which both apps need — without it, App Service unloads an idle app and the next visitor waits for a cold container start. Linux is required for container web apps.
Sharing one plan is a deliberate trade-off: cheap, but the two apps compete for the same CPU and memory. Splitting them onto separate plans would isolate them, at roughly double the cost.
The generated JWT key random_id
The API needs a secret to sign tokens with. Generating one with a plain function would produce a new value on every plan, and every apply would silently invalidate every issued token. Declaring it as a random_id resource means Terraform generates it once, stores it in state, and reuses that same value forever after — so re-applying doesn't log everybody out.
byte_length = 48 produces 48 random bytes; the app setting uses the .b64_std attribute, which is the standard base64 rendering of those bytes. The manual scripts achieve the same stability differently — by reading back any existing key before generating a new one.
The three web apps containers, not code
This block is what distinguishes a container web app from a source-code one. Instead of naming a language runtime, it names an image and the registry to fetch it from — "metals-api:${var.image_tag}", built from the image_tag variable. Azure runs that image's own CMD; there's no startup command to configure.
Azure probes this path and takes an instance out of rotation if it stops answering. Both images expose the same endpoint — Flask for the API, a hardcoded Nginx response for the UI. health_check_eviction_time_in_min = 2 is how long an unhealthy instance is given before removal, and the provider requires it whenever a path is set.
https_only = true redirects plain HTTP. ftp_publish_basic_authentication_enabled and webdeploy_publish_basic_authentication_enabled are both false, disabling the legacy username/password publishing endpoints — deployment happens through the registry and OIDC, so those doors have no reason to be open.
It records the filesystem log retention the platform applies anyway. Declaring it explicitly stops Terraform from seeing Azure's defaults as unmanaged configuration and proposing to strip them on every plan — a small but common source of perpetual “changes” in a plan that should be empty.
Identity and AcrPull how the image gets pulled
A private registry needs authentication. Rather than storing credentials, each app is given its own identity and that identity is granted read access — three cooperating pieces that are easy to miss individually.
- identity { type = "SystemAssigned" } — Azure creates an identity tied to this app's lifetime. Delete the app and the identity goes with it.
- container_registry_use_managed_identity = true — tells App Service to authenticate to the registry as that identity instead of looking for a stored credential.
- The azurerm_role_assignment — grants that identity AcrPull, scoped to the registry. Without this the app has an identity but no permission, and startup fails with an image-pull error.
principal_id = azurerm_linux_web_app.api.identity[0].principal_id reads the ID Azure generated. That reference is also what tells Terraform the app must exist before the role assignment can be created — no explicit depends_on needed.
The apps only ever need to download images, so that's all they're granted — no push, no delete, no visibility into other resources. The identity that does need to build and push is GitHub's, and it's granted separately in github_oidc.tf.
App settings the same image, configured twice
Both images are environment-agnostic, so everything environment-specific arrives here. Terraform's advantage is visible in these maps: values like the database hostname and the API's public URL are references to other resources, so they can't drift or be mistyped.
| Setting | API | UI | Where the value comes from |
|---|---|---|---|
WEBSITES_PORT | 5000 | 8080 | Literal — must match the port inside each image. |
WEBSITES_ENABLE_APP_SERVICE_STORAGE | false | false | Literal — containers here are stateless. |
SCM_DO_BUILD_DURING_DEPLOYMENT | false | false | Literal — no source build step exists any more. |
DB_HOST | ✓ | — | azurerm_postgresql_flexible_server.metals.fqdn — read from the real server. |
DB_NAME | ✓ | — | azurerm_postgresql_flexible_server_database.metals.name. |
DB_USER / DB_PASSWORD / DB_PORT | ✓ | — | local.db_user, var.db_password, and a literal port. |
PGSSLMODE | require | — | Literal — Azure PostgreSQL requires TLS. |
JWT_SECRET_KEY | ✓ | — | random_id.jwt_secret.b64_std — generated once. |
API_UPSTREAM | — | ✓ | "https://${azurerm_linux_web_app.api.default_hostname}" — the API's real hostname, whatever Azure assigned. |
NGINX_RESOLVER | — | 168.63.129.16 | Literal — Azure's platform DNS, overriding the image's Docker default. |
Key terms glossary
- App Service Plan
- The compute capacity web apps run on in Azure — its size and pricing tier. Distinct from the web app resources themselves, which are applications placed onto that capacity. Several apps can share one plan.
- Container registry
- A server that stores built container images so other systems can pull them. Azure Container Registry is the private, Azure-hosted equivalent of Docker Hub.
- Managed identity
- An Entra ID identity that Azure creates and manages for a resource, with no password for anyone to store or rotate. A system-assigned one lives and dies with the resource it belongs to.
- Role assignment
- The grant that connects an identity, a role (a set of permitted actions), and a scope (the resources it applies to). Without one, an identity can authenticate but do nothing.
- Environment variable
- A named value made available to a running program by its environment rather than hardcoded in source. Terraform's
app_settingsmap becomes these inside each container. - Image tag
- The label identifying a specific version of an image in a registry, like
metals-api:latest. Reusing a tag for new content is convenient but means a restart is needed to pick it up.