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.
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
Walking through the script PowerShell version
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.
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.
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.
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.
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.
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.
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.
$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.
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.
| Setting | On | Why |
|---|---|---|
WEBSITES_PORT | both | Tells 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_STORAGE | both | false — don't mount Azure's shared file storage into the container. Containers here are stateless. |
SCM_DO_BUILD_DURING_DEPLOYMENT | both | false — there is no source build step any more; the image arrives finished. |
DB_HOST, DB_PORT, DB_NAME, DB_USER, DB_PASSWORD | API | The individual connection values metals_api/database/connection.py reads. |
PGSSLMODE | API | require — Azure PostgreSQL demands TLS, and the driver picks this up from the environment. |
JWT_SECRET_KEY, JWT_EXPIRATION_MINUTES | API | The 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_DEBUG | API | 0 — never run a debugger in a deployed environment. |
API_UPSTREAM | UI | The API's real https:// hostname, read back from Azure, which Nginx proxies /api/ to. |
NGINX_RESOLVER | UI | 168.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. |
Rerunning it safely -SkipProvisioning
-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.
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 see | Likely cause | How 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.ContainerRegistry | The 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 server | The 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 refused | The 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 page | Expected — no image has been pushed to the registry yet. | Run az_deploy to build and deploy the images. |