Building a Personal Finance App, Part 5: Deploying to Azure Container Apps
This is the part where the app left my laptop. I packaged both services as Docker images, described the entire Azure environment in Bicep, and wrote a GitHub Actions pipeline that builds, tests, deploys, and smoke-tests on every push to main. The first version went in on 2026-10-04. Getting it to actually deploy took eight more pull requests the next day, and each one taught me something specific. I’ll walk through the design, then the fixes one by one, then what changed in the weeks after (the database, the cost, cold starts, and a CI gate for migrations).
In this post you’ll learn:
- How to containerize an ASP.NET Core app with a multi-stage, non-root, chiseled Dockerfile
- How to describe Azure Container Apps, Log Analytics, and Azure SQL with Bicep modules
- How a GitHub Actions pipeline can deploy to Azure with no stored password (OIDC)
- Eleven real pipeline failures and the fix for each: YAML, expression coercion, tags, regions, naming, and caches
- How to test migrations against a real SQL Server in CI and block the deploy if they fail
The Problem
The goals and constraints going in:
- Very low cost. The planning doc in the first deploy commit targeted “$0.00 to <$2.00 / month”. It’s a personal project, so idle time should cost close to nothing.
- Two services.
PersonalFinance.Webis public.PersonalFinance.ApiServiceshould only be reachable by Web. - A real database. Locally the app used a SQLite file. A container’s file system goes away when it restarts or scales to zero, so that had to change.
- No secrets in Git or images. Google, Brevo, and database credentials must flow from GitHub secrets into the running containers.
- Repeatable. I wanted to delete the whole environment and get it back from code.
The service I chose was Azure Container Apps (ACA), a serverless container platform. On its Consumption plan, an app with minReplicas: 0 scales to zero: no replica runs while there’s no traffic, and the first request starts one (a cold start).
The Architecture
This is what the pipeline builds. Names are generated by Bicep; <hash> is explained later.
GitHub Azure resource group
+------------------------------------+ +-------------------------------------------+
| push to main | | Log Analytics workspace (law-*) |
| | | OIDC | ^ console logs |
| v |-------->| Container Apps environment (cae-*) |
| GitHub Actions (deploy-azure.yml) | Bicep | +-------------+ +---------------+ |
| | docker push <short-sha> | | | web-* |----->| api-* | |
| v | | | external | | | |
| GHCR: personalfinance-web / -api |-------->| | HTTPS | | | |
+------------------------------------+ pull | +------+------+ +-------+-------+ |
| | | |
browser ----HTTPS--------->| +-----> Azure SQL <---+ |
| (sql-*, Basic) |
+-------------------------------------------+
The images live in GitHub Container Registry (GHCR) instead of Azure Container Registry, which saves a monthly registry fee.
Step 1: Containerizing Both Apps
Each app has a multi-stage Dockerfile. A multi-stage build uses a big SDK image to compile and a small runtime image to run, so compilers never ship to production. Here is the trimmed PersonalFinance/src/PersonalFinance.ApiService/Dockerfile. Look at the order of the COPY lines:
ARG SDK_IMAGE=mcr.microsoft.com/dotnet/sdk:10.0-noble
ARG RUNTIME_IMAGE=mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled-extra
FROM ${RUNTIME_IMAGE} AS base
WORKDIR /app
EXPOSE 8080
ENV ASPNETCORE_HTTP_PORTS=8080
USER $APP_UID
FROM ${SDK_IMAGE} AS build
WORKDIR /src
# Shared MSBuild files first, then only the .csproj files, then restore
COPY ["PersonalFinance/Directory.Build.props", "PersonalFinance/Directory.Packages.props", "PersonalFinance/"]
COPY ["PersonalFinance/src/PersonalFinance.Shared/PersonalFinance.Shared.csproj", "PersonalFinance/src/PersonalFinance.Shared/"]
COPY ["PersonalFinance/src/PersonalFinance.ApiService/PersonalFinance.ApiService.csproj", "PersonalFinance/src/PersonalFinance.ApiService/"]
RUN dotnet restore "PersonalFinance/src/PersonalFinance.ApiService/PersonalFinance.ApiService.csproj"
# Source code last
COPY ["PersonalFinance/src/PersonalFinance.ApiService/", "PersonalFinance/src/PersonalFinance.ApiService/"]
FROM build AS publish
RUN dotnet publish "PersonalFinance.ApiService.csproj" -c Release -o /app/publish --no-restore /p:UseAppHost=false
FROM base AS final
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "PersonalFinance.ApiService.dll"]
Why it’s built this way:
- Layer caching. Docker caches each step. Because only project files are copied before
dotnet restore, the slow restore layer is reused until a.csprojorDirectory.Packages.propschanges. Editing a.csfile only rebuilds the last steps. - Chiseled image.
noble-chiseledis a stripped Ubuntu image with no shell and no package manager. Less software means less attack surface. - Non-root.
USER $APP_UIDruns the app as an unprivileged user. - No
HEALTHCHECK. Chiseled images have no shell to run one in. Container Apps runs its own probes instead (Step 2).
Step 2: Infrastructure as Code with Bicep
Bicep is Azure’s language for describing resources as code. You declare what should exist, and Azure Resource Manager (ARM) creates or updates it to match. infra/main.bicep calls one module per resource:
| Module | Creates | Cost model (from docs/deployment.md) |
|---|---|---|
log-analytics.bicep |
Log Analytics workspace, 30-day retention | First 5 GB/month of ingestion free |
container-env.bicep |
Container Apps environment (Consumption) | No charge for the environment itself |
sql-database.bicep |
SQL server, firewall rules, database (optional) | Basic tier, ~$5/month |
api-service.bicep |
API Container App, internal ingress | Consumption, scales to zero |
web-service.bicep |
Web Container App, external HTTPS | Consumption, scales to zero |
ARM works out the order from references between modules, for example the Web module needs the API’s address, and runs the independent ones in parallel.
Secrets flow
A secret is passed as a @secure() Bicep parameter (redacted in deployment history), stored as a Container App secret, and exposed to the app as an environment variable that references it. From infra/modules/api-service.bicep, trimmed:
var secrets = concat(
!empty(googleDriveApiKey) ? [
{
name: 'google-drive-api-key'
value: googleDriveApiKey
}
] : [],
!empty(dbConnectionString) ? [
{
name: 'db-connection-string'
value: dbConnectionString
}
] : []
)
The concat + ternary pattern only adds a secret when it has a value, so an unset optional key doesn’t create an empty secret. The env var then points at the secret by name (secretRef: 'db-connection-string' for ConnectionStrings__DefaultConnection). The full chain:
GitHub secret -> arm-deploy parameter -> @secure() Bicep param
-> Container App secret -> env var with secretRef -> IConfiguration key
Probes and service wiring
Remember /health and /alive from Part 4? This is where they’re used. From infra/modules/web-service.bicep:
| Probe | Path | Period | Failure threshold | Meaning |
|---|---|---|---|---|
| Startup | /alive |
5s | 30 | Give the app up to ~150s to boot |
| Liveness | /alive |
15s | 3 | Restart if the process stops responding |
| Readiness | /health |
10s | 3 | Stop routing traffic until all checks pass |
Bicep also replaces the Aspire AppHost. Web gets the API’s address through the same configuration keys Aspire would set:
{
name: 'ApiSettings__BaseUrl'
value: 'https://${apiServiceFqdn}'
}
{
name: 'services__apiservice__http__0'
value: 'http://${apiServiceName}'
}
Step 3: The GitHub Actions Pipeline
.github/workflows/deploy-azure.yml started at 290 lines in 34a6077. Today the job graph looks like this:
push / PR / manual
|
+--> build-and-test ----------+
+--> sql-server-migrations ---+ (added 2026-10-21)
+--> validate-infra ----------+--> deploy-azure --> smoke-test --> promote-latest
+--> build-and-push-containers+ (push/manual (curl the (main only:
(matrix: api, web) only) Web URL) sha -> latest)
The ideas worth stealing:
- OIDC, not passwords. The deploy job has
id-token: write. GitHub issues a short-lived token, and Microsoft Entra ID trusts it through a federated credential. No Azure secret is stored in GitHub. - Immutable tags. Images are pushed as
<short-sha>, and the deploy uses that exact tag. Every Container App revision maps to one commit. latestmeans “verified”.promote-latestre-tags the SHA aslatestonly after the smoke test passes. It usesdocker buildx imagetools create, which re-points the manifest without copying layers.- Cheap PR runs. PRs build images but don’t push or deploy. A new push to a PR cancels its old run; runs on
mainqueue instead so a deploy is never cut off:
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
October 5: Eight PRs to a Green Pipeline
The first deploy commit was large: 47 files, about 5,200 lines added, with tests that read the workflow and Bicep files as text and assert on their contents. It still didn’t deploy. Here is the evening of 2026-10-05, in order:
| PR | Commit(s) | Symptom | Fix |
|---|---|---|---|
| #6 | 2616668 |
Workflow file invalid | if: !(...) -> if: ${{ ... }} |
| #7 | 5c61716 |
Tests failed on the Linux runner | Use pwsh off Windows |
| #8 | aa036a8, 983cac1, d541527, 979fd3c (plus the Azure SQL switch, 85be7c7) |
Image not found; hung jobs; SQL skipped on push | Tag prefix, timeouts, event-aware conditions |
| #9 | a752537 |
SQL provisioning failed in the app’s region | Separate sqlLocation |
| #10 | 86ded5f |
SQL server name stuck after a failed deploy | Hash the region into the name |
| #11 | de66875 |
Environment “scheduled for delete” | Wait for deletion; 40-minute timeout |
| #12 | 3f274d0 |
Hard to see which module failed | Move to westus2, region in all names, dump operations on failure |
| #13 | aaba0c3 |
Cache export failed the build | ignore-error=true |
| (direct) | 9f18299 |
Resource group already existed in another region | Reuse it |
Lesson 1: ! at the start of a YAML value is not “not” (#6)
The first workflow had lines like this:
if: !(inputs.dry_run == true)
In YAML, a value starting with ! is a tag (a type annotation), not a boolean operator, so the file didn’t parse the way I meant. Wrapping the expression in ${{ }} makes it a string to YAML and an expression to GitHub:
if: ${{ inputs.dry_run != true }}
Lesson 2: Test helpers must work on the CI operating system (#7)
BicepInfrastructureTests launched powershell.exe, which doesn’t exist on the Ubuntu runner:
FileName = OperatingSystem.IsWindows() ? "powershell.exe" : "pwsh",
Lesson 3: docker/metadata-action adds a sha- prefix (#8)
The pipeline pushed personalfinance-api:sha-3d8c356, but told Bicep to deploy personalfinance-api:3d8c356. Container Apps failed with MANIFEST_UNKNOWN. Setting an empty prefix makes the two match:
tags: |
type=sha,format=short,prefix=
type=raw,value=latest,enable={{is_default_branch}}
The same fix added a docs note: new GHCR packages are private by default. They have to be made public, or given pull credentials, before Container Apps can pull them.
Lesson 4: Every job needs a timeout (#8)
d541527 added timeout-minutes to every job (15 for build, 20 for images, 25 for deploy, 10 for smoke tests). The default is six hours, so a stuck step can waste a lot of runner time. The smoke test’s curl calls also got --connect-timeout 5 --max-time 10, and the script now fails fast if the Web URL from the deploy outputs is empty.
Lesson 5: On a push, inputs.* is null, and null == false (#8)
This one is subtle. Before 979fd3c, the Bicep parameter was:
deploySqlDatabase=${{ inputs.deploy_sql != false }}
inputs only has values for manual (workflow_dispatch) runs. On a push, inputs.deploy_sql is null. GitHub expressions use loose equality: when types differ, both sides are converted to numbers, and both null and false become 0. So null != false is false, and every push to main asked Bicep for deploySqlDatabase=false: no database, with no error. The fix checks the event type explicitly:
deploySqlDatabase=${{ github.event_name != 'workflow_dispatch' || inputs.deploy_sql == true }}
The same commit made the deploy job run only for push and workflow_dispatch, kept PRs from pushing images, and added a step that fails early if SQL_ADMIN_PASSWORD (or CUSTOM_CONNECTION_STRING) is missing. 983cac1 added one more debugging aid: the migration step now logs the target database host, because a missing connection string showed up as a confusing SocketException.
Lesson 6: Azure SQL can live in a different region (#9)
The pipeline was still failing on the SQL step. The fix was a new sqlLocation parameter, so SQL no longer has to be in the same region as Container Apps (eastus at the time). Its description says why:
@description('Azure region for Azure SQL resources. This can differ from the Container Apps region when SQL provisioning is restricted.')
param sqlLocation string = 'westus2'
The same PR added an “Ensure Azure resource group exists” step so the deploy doesn’t depend on someone creating the group by hand.
Lesson 7: Failed deployments can leave a name reserved (#10, #12)
Bicep names were built as sql-${environmentName}-${uniqueString(resourceGroup().id)}. uniqueString is a hash: the same inputs always give the same output, so names are stable across redeploys. But after a failed attempt in one region, the SQL server name stayed reserved there. Adding the region to the hash gives a different, still deterministic name per region:
// Azure SQL server names are location-bound, and a failed deployment can leave
// a name reserved in the original region even when provisioning did not complete.
var sqlServerName = 'sql-${environmentName}-${uniqueString(resourceGroup().id, sqlLocation)}'
PR #12 then moved everything to westus2 and used the same uniqueString(resourceGroup().id, location) suffix for every resource. It also renamed the apps from the fixed apiservice/web to api-<env>-<hash> and web-<env>-<hash>.
Lesson 8: An environment being deleted blocks a new one (#11)
A Container Apps environment from an earlier attempt was in the ScheduledForDelete state, and the new deployment collided with it. de66875 added a step that finds any cae-<env>-* environment, checks its provisioningState, and if it is being deleted, polls every 30 seconds for up to 15 minutes. The deploy job timeout went from 25 to 40 minutes to make room.
Lesson 9: When ARM fails, ask it which module failed (#12)
The arm-deploy action’s error message was often too vague to act on. A step with if: ${{ failure() }} now lists the operations of the main deployment and of each module (logAnalyticsDeployment, containerEnvDeployment, sqlDatabaseDeployment, and so on) with their state and error message:
az deployment operation group list \
--resource-group "$RESOURCE_GROUP" \
--name main \
--query "[].{state:properties.provisioningState,target:properties.targetResource.resourceName,error:properties.statusMessage.error.message}" \
--output json
If you take one thing from this list, take this one. It turned “deployment failed” into a specific resource and a specific error.
Lesson 10: A cache is an optimization; don’t let it fail the build (#13)
Exporting the Docker layer cache to the GitHub Actions cache sometimes failed, and that failed the whole image build. One flag fixed it:
cache-from: type=gha
cache-to: type=gha,mode=max,ignore-error=true
(The next day, 7f5d50f gave the two images separate cache scopes, scope=api and scope=web, so they stop overwriting each other.)
Lesson 11: A resource group’s region is just metadata (9f18299)
az group create fails if the group already exists in a different region. But a group’s location only says where its metadata is stored; the resources inside are placed by the Bicep location parameter. The step now reuses an existing group:
if [[ "$(az group exists --name "${{ env.AZURE_RESOURCE_GROUP }}")" == "true" ]]; then
EXISTING_LOCATION=$(az group show --name "${{ env.AZURE_RESOURCE_GROUP }}" --query location --output tsv)
echo "Resource group already exists in '${EXISTING_LOCATION}'; resources will deploy to '${{ env.AZURE_LOCATION }}'."
exit 0
fi
Step 4: From SQLite to Azure SQL
The first deploy commit supported two database providers, SQLite and SQL Server, each with its own EF Core migration folder and a helper that guessed the provider from the connection string. That meant two migration sets to keep in sync for one app.
85be7c7 (2026-10-05) removed SQLite entirely: 35 files changed, about 1,900 lines deleted. What remained:
- SQL Server (Azure SQL in the cloud, SQL Server locally) as the default
- EF Core InMemory for tests, selected with a connection string like
InMemory:<name>
A related change from the first deploy commit: ASP.NET Core Data Protection keys (used to encrypt auth cookies) are stored in the database with PersistKeysToDbContext<AppDbContext>(). Without that, keys live in the container and are lost on every restart or scale-to-zero, which signs everyone out.
Step 5: Cost Optimization (and a Flip-Flop)
The original plan used the Azure SQL serverless free tier, which auto-pauses when idle. On 2026-10-11 the database tier changed three times in one day:
| Commit | SKU | Stated reason |
|---|---|---|
133fd08 |
Basic | “Predictable costs”; the Bicep budget comment estimates ~$10/month total |
b9b86a7 |
Serverless GP_S_Gen5_1 with useFreeLimit: true and freeLimitExhaustionBehavior: 'AutoPause' |
“$0 cost option” |
d6885de |
Basic | “Predictable ~$5/month costs”; “always-on, 2 GB storage, 5 DTUs” |
The final module is short:
// Azure SQL Database — Basic tier (~$5/month)
// Most budget-friendly option: always-on, 2 GB storage, 5 DTUs
resource sqlDatabase 'Microsoft.Sql/servers/databases@2023-08-01-preview' = {
parent: sqlServer
name: databaseName
location: location
sku: {
name: 'Basic'
tier: 'Basic'
}
}
The trade-off I settled on: a few dollars a month for a database that is always on and doesn’t pause, over a free tier whose pausing behavior I’d have to plan around.
The same commits tuned the containers for faster cold starts:
- ReadyToRun (
/p:PublishReadyToRun=true) compiles IL to native code at publish time, so less JIT work happens at startup. It needs a runtime identifier at both restore and publish (-r linux-x64), whichb9b86a7added. DOTNET_TieredPGO=1in the image environment.- Trimming stays off, because EF Core relies on reflection that aggressive trimming breaks.
- An optional scheduled scaling rule (a KEDA cron scaler) that can keep one replica warm during set hours. It’s behind
enableScheduledScaling, which defaults tofalse.
Step 6: Smoke Tests and Cold Starts
With minReplicas: 0, the first request after a deploy (or after idle time) waits for a container to start. The smoke test has to tolerate that. It calls the Web app in order:
/health -> retry until 200 (cold start)
/alive -> 200
/ -> 200 or 302
/Identity/Account/Login -> 200
On 2026-10-16 (bc47b91) the /health retry count went from 30 to 60 (5-second intervals). Each failed check also dumps response headers and the first 400 bytes of the body, which beats guessing.
Users hit cold starts too, and the API scales to zero separately from Web. On 2026-10-21 (3498a91) I added ApiServiceWarmer: when the Login page loads, Web sends an anonymous ping to the API’s /alive, at most once every two minutes. By the time the user signs in and the dashboard asks for data, the API container is usually already starting.
Step 7: Gating the Deploy on Real Migrations
Unit tests run against EF Core InMemory, which doesn’t run migrations at all. A broken SQL Server migration would only show up when a new revision started in Azure. Two commits on 2026-10-21 closed that gap.
First, 73bbdd5 added a local SQL Server 2022 container in compose.yaml, with npm run db:up, db:down, and db:reset scripts. The password comes from a git-ignored .env file:
services:
sqlserver:
image: mcr.microsoft.com/mssql/server:2022-latest
platform: linux/amd64
environment:
ACCEPT_EULA: "Y"
MSSQL_PID: Developer
MSSQL_SA_PASSWORD: ${MSSQL_SA_PASSWORD:?MSSQL_SA_PASSWORD is not set. Copy .env.example to .env and set a strong sa password.}
ports:
- "${SQL_HOST_PORT:-1433}:1433"
Second, 48fa8d9 added a sql-server-migrations CI job that reuses the same compose file:
- Write a
.envwith a random, maskedsapassword for this run only (no repository secret needed). npm run db:upand wait for the container’s health check.- Run the ApiService with
--migrate-onlytwice. The first run must record migrations in__EFMigrationsHistory; the second must apply nothing, which proves the migrations are idempotent (safe to run again). - Always run
db:down.
deploy-azure now lists it in needs:, so a bad migration blocks the deploy.
Gotchas and Lessons Learned
Beyond the eleven pipeline lessons above:
1. The app exited with code 0 when startup failed. While writing the migrations job I found that both Program.cs files caught the fatal startup exception, logged it, and exited 0. A failed --migrate-only run looked like a success, so the first version of the job had to grep the log for "Database migration task finished (--migrate-only); exiting.". b5b0129 fixed the root cause with a small StartupExit helper in ServiceDefaults:
catch (Exception ex) when (StartupExit.IsShutdown(ex, applicationStopping))
{
return StartupExit.Success; // clean shutdown or EF tooling (HostAbortedException)
}
catch (Exception ex)
{
Log.Fatal(ex, "Application terminated unexpectedly.");
return StartupExit.Failure; // 1: CI, containers and jobs can now see the crash
}
The CI job now trusts the exit code and checks the __EFMigrationsHistory row count instead of log text. Lesson: containers, CI jobs, and orchestrators only see your exit code. Make it honest.
2. A build refactor broke the Docker build. On 2026-10-21 (d3dc92a) I moved TargetFramework, Nullable, and ImplicitUsings into a shared Directory.Build.props. The local build was fine, but both Dockerfiles only copied Directory.Packages.props before restore, so restore inside the image saw no target framework (NETSDK1013). 9f6b697 copies both files, and a test now fails if a Dockerfile stops doing so. Lesson: a Docker build context is a separate world; any file MSBuild picks up implicitly has to be copied in explicitly.
3. Bicep has no inline if for array items. The first scheduled-scaling rule in 133fd08 put an if (enableScheduledScaling) { ... } block inside the rules array. Bicep only supports if on whole resources and modules, so b9b86a7 rewrote it as concat([httpRule], enableScheduledScaling ? [cronRule] : []). It’s the same pattern as the secrets list above.
4. Text-based tests on YAML are brittle, but they caught real drift. Almost every fix PR also had to update GitHubActionsDeploymentWorkflowTests or BicepInfrastructureTests, because they assert on exact strings. That’s busywork. The kind of test that pays off is the one from gotcha 2: it checks one specific rule (“both MSBuild files are copied before restore”) that already broke once.
5. Docs drift. The workflow_dispatch input for deploy_sql is still labeled “Serverless Free Tier ($0.00)”, and parts of the deployment guide still talk about auto-pause, even though the database has been Basic tier since d6885de. Labels and descriptions are code too.
Recap
- Multi-stage, chiseled, non-root Dockerfiles with project files copied before restore give small images and fast rebuilds.
- Bicep describes the whole environment: Log Analytics, a Consumption Container Apps environment, two scale-to-zero apps, and an optional Azure SQL database. Secrets flow through
@secure()params into Container App secrets. - The pipeline uses OIDC instead of stored credentials, immutable SHA tags, and promotes
latestonly after smoke tests pass. - Most first-deploy failures were small and specific: a YAML
!, null-vs-false coercion, asha-prefix, regions, reserved names, and caches. Dumping ARM deployment operations on failure made them findable. - Azure SQL Basic (~$5/month, as stated in the commit) replaced SQLite, and a CI job now runs migrations twice against real SQL Server 2022 before any deploy.
- Exit codes matter: a startup failure must exit non-zero, or CI and containers can’t tell it apart from success.
Series: Overview · Previous: Part 4: .NET Aspire and Observability · Next: Part 6: Net Worth Projection and FI Planning
Source code: tngo0508/PersonalFinance
Leave a comment