TutorialsDeployment › This Project's Workflows

Deployment · Page 2 of 3

Three Workflows, One Rule

This repository has three workflow files: one per deployable application, plus one for infrastructure. They share a single governing principle — every push is validated, but nothing reaches Azure without either a merge to main or a deliberate click — and infrastructure is held to the stricter half of that rule at all times.

Codey the Sr Developer, standing with a pointer, ready to walk through the guide
Workflows → 3 Auto-deploys on → push to main Infrastructure apply → manual only Image tag → latest

Purpose of this page

Know which workflow runs when, what each job does, and why the API and UI have separate files rather than one shared pipeline.

Codey typing on a laptop
two apps, one infrastructure

The three files overview

FileWhat it does, and what wakes it up
deploy-api.ymlTests the API, then builds, pushes, and deploys the metals-api image. Triggered by changes under metals_api/, sql/, or the requirements files.
deploy-ui.ymlBuilds, pushes, and deploys the metals-ui image. Triggered by changes under metals_ui/.
terraform.ymlValidates the Terraform configuration on every change, and runs plan, apply, or destroy only when a person asks.

Each file's resource names live in a top-level env: block — RESOURCE_GROUP, REGISTRY_NAME, the Web App names, and IMAGE_TAG. If you ever change the user_name these resources are built from, those are the values to update.

The rule that decides what runs the important part

Both application workflows trigger on pushes, pull requests, and manual dispatch — but the jobs that touch Azure carry an if: condition, so a trigger alone isn't enough to deploy anything.

Codey holding up a sticky note
validate always, deploy deliberately
the condition on every Azure-touching jobif: github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref == 'refs/heads/main')
What you diddeploy-api.ymldeploy-ui.ymlterraform.yml
Opened or updated a PRTests onlyLocal image build onlyfmt, validate, test
Pushed to a feature branchTests onlyLocal image build onlyfmt, validate, test
Merged a PR to mainTest → build → deployBuild → deployfmt, validate, test
Clicked Run workflowTest → build → deployBuild → deployplan / apply / destroy, as selected

Read the terraform.yml column again: there is no row where a push changes infrastructure. Merging Terraform code to main validates it and stops. Applying always takes a human, because a bad infrastructure change can delete a database, while a bad application image can be replaced by pushing a better one.

deploy-api.yml three jobs

A straight chain: testbuild-and-pushdeploy, each job waiting on the one before it via needs:.

testruns on every trigger

Starts a postgres:16 service container, loads sql/metals-db.sql into it with psql, then runs pytest metals_api/tests against that real database. This is the only job with no if: — it always runs, which is the point: every push and every PR gets tested.

build-and-pushgated

Logs in to Azure with azure/login, then runs a single command:

az acr build --registry "$REGISTRY_NAME" --resource-group "$RESOURCE_GROUP" \
  --image "metals-api:$IMAGE_TAG" --file metals_api/Dockerfile .

The build happens on Azure's own build agents, not the GitHub runner — the runner only uploads the source. That's why no Docker setup step is needed, and why the image never travels across the internet twice.

deploygated, and declares the URL

Points the Web App at the image, restarts it, then polls /health up to ten times with a ten-second wait, failing the run with ::error:: if it never returns 200. Its environment: block includes a url:, so the deployed address is linked directly from the run summary and the repository's Environments view.

It declares contents: none and skips actions/checkout entirely — this job never needs repository files, only the Azure CLI and its OIDC token.

deploy-ui.yml, and why it's separate a structural decision

The obvious design is one workflow with an API job and a UI job. It doesn't work, for a specific reason: paths: filters apply to the entire workflow, not to individual jobs. One shared file would mean every UI tweak also rebuilt and redeployed the API, and vice versa.

Codey thinking with a rubber duck
one filter per file
Separate files, independent deploysthe payoff

Editing a stylesheet under metals_ui/ triggers only the UI workflow; changing a route under metals_api/ triggers only the API's. Each app ships on its own schedule, and an unrelated change can't cause an unnecessary restart of the other.

No test job — a build check insteadan honest difference

The UI has no automated test suite, so on pushes and PRs its build job runs a plain docker build on the runner — no registry, no Azure login, no credentials involved. It proves the image still builds, which is the useful signal available here.

That validation job is deliberately a separate job from build-and-push rather than one job with conditional steps, because environment: is a job-level setting — keeping them apart means an ordinary PR never touches the Development environment or its protection rules.

working-directory: metals_uia gotcha, encoded

The UI's az acr build step sets its working directory before running. That's not cosmetic: az acr build validates the --file path against the current directory rather than the source argument, so running it from the repository root with --file Dockerfile metals_ui fails with Unable to find 'Dockerfile'. The same workaround appears in the manual deploy script.

terraform.yml infrastructure, manually

Two jobs: one that always validates, and one that only runs on workflow_dispatch and performs the operation you picked from a dropdown.

validateno Azure credentials at all

Runs fmt -check, init -backend=false, validate, and test for both the main configuration and the bootstrap module, then the Python unit tests for the schema loader. Everything here is offline — it never authenticates to Azure and can never change a resource.

infrastructureplan, apply, or destroy

Guarded by if: github.event_name == 'workflow_dispatch'. It checks that every required secret and variable is actually set before doing anything, connects Terraform to its remote state in Azure Blob Storage, produces a plan, and applies that same plan in the same run if the operation wasn't plan.

The operation defaults to plan, so the least destructive choice is the one you get by clicking through without thinking.

The temporary firewall rulea neat, careful touch

To load the database schema after an apply, the runner must reach PostgreSQL — but GitHub's runners have unpredictable IP addresses. The workflow looks up its own public IP, adds a firewall rule named after the run, initializes the database, and removes the rule in a cleanup step marked if: always() so it runs even when the initialization failed.

The run summarywhere to find the IDs

After an apply it appends the Terraform outputs to $GITHUB_STEP_SUMMARY — the client/tenant/subscription IDs for the application deployment identity, both Web App names, and the registry login server. Those are the values that go into the Development environment's secrets, covered on the next page.

Running them in practice

Day to day you don't run these at all — you merge a pull request and the right one fires. Manual dispatch is for first-time setup, infrastructure changes, and redeploying without a new commit.

Codey pointing to the right
merge, or click
#GoalHow
1Ship an API changeMerge it to main. Tests run, then the image builds and deploys automatically.
2Redeploy without changing codeActions → Build and deploy metals-api to Azure → Run workflow.
3Create or update Azure resourcesActions → Terraform infrastructure → Run workflow, operation apply.
4Preview an infrastructure changeSame, operation plan — reads state and Azure, changes nothing.
5Tear the environment downSame, operation destroy. Deletes the database and all its data.

Common errors and how to fix them

What you seeLikely causeHow to fix it
Tenant '***' not found during azure/loginA malformed or empty AZURE_TENANT_ID — usually a copy-paste slip when the secret was added.Re-enter the three AZURE_* secrets on the Development environment. See the next page.
AuthorizationFailed on a registry actionThe deployment identity is missing a role, or one was granted moments ago and hasn't propagated.Check the roles listed on the next page; allow a few minutes after any role change.
Unable to find 'Dockerfile' during az acr buildThe step ran from a directory where that relative path doesn't exist.Ensure the UI step keeps its working-directory: metals_ui.
Workflow didn't run at all after a pushThe commit didn't touch any path in that workflow's paths: filter.Expected behavior. Use Run workflow to deploy anyway.
Deploy jobs show as skipped on a PRThe if: gate — PRs validate but never deploy.Also expected. Merge to main, or dispatch manually.
Deploy succeeded but the site is unchangedA new image was pushed under the same tag and nothing restarted.Confirm the restart step ran; see why the restart exists.
Codey giving a thumbs up

The workflows make sense — but how do they get into Azure without a password? That's next.