TutorialsTerraform › app_service.tf

Terraform Tutorial · File 6 of 9

app_service.tf

The largest file in the configuration. It creates the container registry every image is published to, the shared App Service plan, and the three container web apps — API, UI, and the tutorial site — each with its own managed identity and permission to pull from that registry. It also generates the API's JWT signing key and hands both apps every setting they need.

Codey the Sr Developer, standing with a pointer, ready to walk through the guide
Resources created → 9 Runtime → Linux containers Plan tier → B1 (Basic), shared by all three apps Registry auth → managed identity

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.

Codey typing on a laptop
six resources, one file

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 UI app and its ui_acr_pull assignment follow the same shape, differing only in the image name, the port, and their app settings — covered in App settings below.

The container registry where images live

name = replace("${local.prefix}acr", "-", "")a naming rule, enforced in code

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.

admin_enabled = falseno username and password

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.

sku = "Basic"sized for this project

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.

Codey pointing to the left
rent once, host twice
sku_name = "B1", os_type = "Linux"the smallest always-on tier

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

Why a resource, not a functionthe crucial detail

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

application_stack with docker_image_namewhat makes it a container app

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.

health_check_path = "/health"and its eviction window

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 and the two publish settingshardening

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.

The logs blockpresent to prevent drift

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.

Codey holding a magnifying glass
three pieces, one pull
  1. identity { type = "SystemAssigned" } — Azure creates an identity tied to this app's lifetime. Delete the app and the identity goes with it.
  2. container_registry_use_managed_identity = true — tells App Service to authenticate to the registry as that identity instead of looking for a stored credential.
  3. 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.

AcrPull and nothing moreleast privilege

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.

SettingAPIUIWhere the value comes from
WEBSITES_PORT50008080Literal — must match the port inside each image.
WEBSITES_ENABLE_APP_SERVICE_STORAGEfalsefalseLiteral — containers here are stateless.
SCM_DO_BUILD_DURING_DEPLOYMENTfalsefalseLiteral — no source build step exists any more.
DB_HOSTazurerm_postgresql_flexible_server.metals.fqdn — read from the real server.
DB_NAMEazurerm_postgresql_flexible_server_database.metals.name.
DB_USER / DB_PASSWORD / DB_PORTlocal.db_user, var.db_password, and a literal port.
PGSSLMODErequireLiteral — Azure PostgreSQL requires TLS.
JWT_SECRET_KEYrandom_id.jwt_secret.b64_stdgenerated once.
API_UPSTREAM"https://${azurerm_linux_web_app.api.default_hostname}" — the API's real hostname, whatever Azure assigned.
NGINX_RESOLVER168.63.129.16Literal — Azure's platform DNS, overriding the image's Docker default.

The API_UPSTREAM row is worth pausing on. Because it's a reference rather than a hardcoded string, Terraform knows the UI depends on the API and creates them in the right order — and if the API's hostname ever changed, the UI's setting would update with it automatically. It's also why the UI can't be created before the API exists. Background on both UI settings is on the UI image page.

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_settings map 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.
Codey giving a thumbs up

Both apps have somewhere to run and know how to reach the database and the registry — next, how GitHub gets in.