TutorialsDeploymentManual › az_create_resources

Manual Deployment · Script 1 of 4

az_create_resources

Creates every Azure resource this project needs, directly with the Azure CLI: the resource group, the PostgreSQL server and database, a container registry, and two container web apps — one for the API, one for the UI — then loads the starting database tables and configures every application setting. It creates infrastructure only; the images themselves are built later by az_deploy.

Codey the Sr Developer, standing with a pointer, ready to walk through the guide
Files → az_create_resources.ps1 · .sh Web apps created → 3, sharing one plan Registry auth → managed identity, no passwords Resume with → -SkipProvisioning Terraform equivalent → main.tf, database.tf, app_service.tf

Purpose of this script

Provision every Azure resource this project needs, in order, using individual az commands — then load the starting database schema and data.

Codey typing on a laptop
one command per resource

This is the manual equivalent of running terraform plan and terraform apply against main.tf, database.tf, and app_service.tf together — except each Azure resource is created by its own explicit command, with no plan preview and no state file tracking what already exists.

Before you run it prerequisites

  • Azure CLI installed, and authenticated (az login) as an identity that can create resources and assign roles — the script grants each web app AcrPull on the registry.
  • Either psql (the PostgreSQL command-line client) on your PATH, or Python with the psycopg package installed — used to load sql/metals-db.sql into the new database.
  • The Microsoft.ContainerRegistry resource provider registered on the subscription. If it isn't, registry creation fails with MissingSubscriptionRegistration — see Common errors.
  • Docker is not required — this script creates infrastructure and never builds an image.
PowerShell, from the repository root.\utility_scripts\az_create_resources.ps1 -UserName dzierzon

The script prompts for the PostgreSQL administrator password as a hidden SecureString rather than accepting it on the command line, so it never lands in your shell history. Save what you type — you'll need the same value to resume with -SkipProvisioning, and it's what the API is configured with.

Walking through the script PowerShell version

1. Resolve your public IP addressbefore anything is created
if (-not $ClientIp) {
    $ClientIp = ([string](Invoke-RestMethod -Uri 'https://api4.ipify.org' -TimeoutSec 15)).Trim()
}

Calls a public “what's my IP” service so the script knows which address to open the database firewall for later. The same idea as the client_ip variable in Terraform — pass -ClientIp explicitly if you're behind a VPN or proxy with a different egress address.

2. Resource groupaz group create
az group create --name $resourceGroup --location $Location

The same purpose as main.tf's azurerm_resource_group — the container every other resource below gets placed into.

3. PostgreSQL server and databaseaz postgres flexible-server create
az postgres flexible-server create `
    --resource-group $resourceGroup `
    --name $dbServer `
    --location $Location `
    --admin-user metalsadmin `
    --admin-password $dbPassword `
    --tier Burstable `
    --sku-name Standard_B1ms `
    --storage-size 32 `
    --storage-auto-grow Disabled `
    --backup-retention 7 `
    --geo-redundant-backup Disabled `
    --version 16 `
    --public-access 0.0.0.0 `
    --tags Environment=Development

az postgres flexible-server db create `
    --resource-group $resourceGroup --server-name $dbServer --name metals

Every flag here has a direct match in database.tf's azurerm_postgresql_flexible_server resource — same Burstable tier, same 32 GB storage, same PostgreSQL version 16, same 0.0.0.0 special-case meaning “allow Azure services.” Reading these two forms side by side is one of the fastest ways to see what Terraform is actually doing underneath its declarative syntax.

$dbPassword is the value you were prompted for, converted from the SecureString only at the moment it's needed.

4. Open the firewall for your own computeraz postgres flexible-server firewall-rule create
az postgres flexible-server firewall-rule create `
    --resource-group $resourceGroup --server-name $dbServer `
    --name "LocalClient-$($ClientIp.Replace('.', '-'))" `
    --start-ip-address $ClientIp --end-ip-address $ClientIp

The manual equivalent of database.tf's local_client firewall rule — without it, the next step (loading data from your own machine) can't reach the server at all.

5. Load the starting data, if the database is emptypsql, with a Python fallback

First it counts the existing tables. If any are found it prints Existing database tables found; preserving schema and data and skips loading entirely — so rerunning the script can't wipe a database you've been using. On an empty database it runs sql/metals-db.sql, using psql if available or a small inline Python script with psycopg if not.

This step has no Terraform equivalent — Terraform only creates the empty database; loading starting data is handled separately by terraform/scripts/initialize_database.py.

6. Container registryaz acr create
az acr create --name $RegistryName --resource-group $resourceGroup `
    --location $Location --sku Basic --admin-enabled false `
    --role-assignment-mode rbac

Creates the private registry every image will be pushed to. Two details matter: --admin-enabled false disables the registry's built-in username/password account entirely, and --role-assignment-mode rbac selects classic role-based access so the AcrPull grant in the next step behaves as expected.

Registry names are globally unique across all of Azure and may contain only letters and digits — hence the generated expeditors<user>metalsacr form, with hyphens stripped. Pass -RegistryName if the generated one is taken.

7. One plan, three container web appsaz appservice plan create, az webapp create
az appservice plan create --name $planName --resource-group $resourceGroup `
    --sku B1 --is-linux --location $Location

# ...then, for each of the API, the UI, and the tutorial site:
az webapp create --name $name --resource-group $resourceGroup `
    --plan $planName --container-image-name $image `
    --assign-identity '[system]' --acr-use-identity --acr-identity '[system]'

All three apps share the single B1 plan — App Service bills per plan instance, not per app, so the UI and tutorial site cost nothing extra to host. Each app gets a system-assigned managed identity (--assign-identity '[system]') and is told to use that identity when pulling from the registry, which is why no registry credentials are ever stored.

The image doesn't exist yet at this point, and that's fine — the app is created, just not serving until az_deploy builds and pushes one.

8. Grant each identity permission to pullaz role assignment create
$principalId = az webapp identity assign --name $name `
    --resource-group $resourceGroup --query principalId --output tsv

az role assignment create --assignee-object-id $principalId `
    --assignee-principal-type ServicePrincipal --scope $registry.id `
    --role AcrPull

An identity with no roles can't do anything. AcrPull, scoped to this one registry, is the minimum needed to download image layers — the app can't push, delete, or read anything else. Matching Terraform resources are the api_acr_pull and ui_acr_pull assignments in app_service.tf.

9. Container config and platform settingsaz webapp config set
az webapp config container set --name $name --resource-group $resourceGroup `
    --container-image-name $image `
    --container-registry-url "https://$($registry.loginServer)"

# generic-configurations, as JSON:
#   acrUseManagedIdentityCreds = true
#   appCommandLine  = ''          # use the image's own CMD
#   alwaysOn        = true
#   healthCheckPath = '/health'

az webapp update --name $name --resource-group $resourceGroup --https-only true

appCommandLine = '' is deliberate: it clears any startup command so the image's own CMD runs — Gunicorn for the API, Nginx for the UI. healthCheckPath points Azure's probe at the same /health endpoint the containers use themselves, and https-only redirects plain HTTP.

The application settings it writes configuration, not code

The images contain no hostnames, passwords, or URLs — all of it arrives as environment variables. These are the settings the script writes, and they're the exact same set Terraform declares in app_service.tf.

Codey holding up a sticky note
same image, different settings
SettingOnWhy
WEBSITES_PORTbothTells Azure which port inside the container to send traffic to — 5000 for the API, 8080 for the UI. Azure doesn't read the Dockerfile's EXPOSE.
WEBSITES_ENABLE_APP_SERVICE_STORAGEbothfalse — don't mount Azure's shared file storage into the container. Containers here are stateless.
SCM_DO_BUILD_DURING_DEPLOYMENTbothfalse — there is no source build step any more; the image arrives finished.
DB_HOST, DB_PORT, DB_NAME, DB_USER, DB_PASSWORDAPIThe individual connection values metals_api/database/connection.py reads.
PGSSLMODEAPIrequire — Azure PostgreSQL demands TLS, and the driver picks this up from the environment.
JWT_SECRET_KEY, JWT_EXPIRATION_MINUTESAPIThe token signing key and lifetime. The key is generated once and preserved on reruns, so re-running the script doesn't invalidate everyone's existing logins.
FLASK_DEBUGAPI0 — never run a debugger in a deployed environment.
API_UPSTREAMUIThe API's real https:// hostname, read back from Azure, which Nginx proxies /api/ to.
NGINX_RESOLVERUI168.63.129.16 — Azure's platform DNS. The image defaults to Docker's 127.0.0.11, which doesn't exist here; see the UI image page.

Settings are written from a temporary JSON file rather than inline arguments, so passwords containing quotes, spaces, or $ survive Windows command-line parsing intact. The file is always deleted afterward.

Rerunning it safely -SkipProvisioning

What the switch skipsand what it doesn't

-SkipProvisioning skips creating the resource group, PostgreSQL server, and database — the three things that fail loudly if they already exist. Everything after that still runs: the firewall rule, the registry, the plan, both web apps, all role assignments, and every application setting.

Supply the existing administrator password when prompted. The switch does not reset it, and the API's DB_PASSWORD setting is written from whatever you type.

It's not a read-only checkworth being clear

Resuming still rewrites configuration and re-applies role assignments. To merely inspect a deployment without changing it, use the read-only az ... show and list commands in utility_scripts/README.md instead. For routine code updates, use az_deploy — not this script.

Common errors and how to fix them

What you seeLikely causeHow to fix it
“Could not detect your public IPv4 address”The api4.ipify.org request failed or timed out (network restrictions, firewall).Rerun with -ClientIp <your address> supplied explicitly.
MissingSubscriptionRegistration for Microsoft.ContainerRegistryThe subscription has never used Azure Container Registry, so the resource provider isn't registered.Run az provider register --namespace Microsoft.ContainerRegistry, wait for az provider show -n Microsoft.ContainerRegistry --query registrationState to report Registered, then resume with -SkipProvisioning.
“Specified server name is already used”PostgreSQL server names are globally unique, and a recently deleted one can stay reserved for a while.Wait a few minutes and retry, or choose a different -UserName.
“A resource with the same name already exists” on the resource group or serverThe script (or a previous run) already created these resources.Either delete first with az_delete_resources, or rerun with -SkipProvisioning to resume from the firewall-rule step onward.
“Database initialization requires psql or Python with psycopg”Neither psql nor a Python environment with psycopg installed could be found.Install PostgreSQL client tools, or run python -m pip install "psycopg[binary]>=3.0,<4.0", or pass -PsqlPath with the full path to psql.exe.
Database initialization times out / connection refusedThe firewall rule for your IP hasn't finished propagating yet, or your network blocks outbound TCP 5432.Wait up to five minutes and rerun with -SkipProvisioning; if on a VPN/proxy, pass -ClientIp with your connection's actual public egress address.
The web apps exist but return an error pageExpected — no image has been pushed to the registry yet.Run az_deploy to build and deploy the images.
Codey giving a thumbs up

The infrastructure exists — next, building the images that will actually run on it.