← Back to Skills

velais-azure-infra

active

Authors, reviews and deploys the Azure infrastructure for a Velais platform: Bicep (.bicep, .bicepparam) on Azure Container Apps, or a VM where ACA cannot serve, with GitHub Actions OIDC CI/CD, PostgreSQL Flexible Server, Key Vault, ACR, Storage, VNet+NAT and .env-driven secret sync. Use when scaffolding infra for a Velais or client project, adding a Container App, Job or VM, wiring OIDC CI/CD or secret sync, binding a custom domain with TLS, reviewing Bicep for Container Apps, porting a stack to a client subscription, or debugging a failed deploy. Also use when writing APPLICATION CODE that will run on Container Apps: a scheduled job, background task or long request, anything that depends on a timeout, a restart or two replicas. Also on: 504 at exactly four minutes, a revision that will not start, "relation does not exist" after deploy, RequireCustomHostnameInEnvironment, CertificateProvisioningError, BCP307, BCP318. Do NOT use for Terraform, AWS, GCP or a single generic Bicep module (use bicep-modules).

Owner: velais Category: infrastructure Version: 1.0.0 Tokens: ~42k
azurebicepcontainer-appsiacci-cdpostgreskey-vaultcustom-domain
92

Quality Score Breakdown

Structure (15%) 18/18
Show checks (11)
  • ✓ SKILL.md exists with exact casing 3/3
  • ✓ Valid YAML frontmatter 3/3
  • ✓ No unexpected frontmatter keys 1/1
  • ✓ Name field valid (kebab-case) 2/2
  • ✓ Name matches folder name 1/1
  • ✓ Description field present 2/2
  • ✓ No angle brackets in frontmatter 1/1
  • ✓ Folder name is kebab-case 1/1
  • ✓ No README.md inside skill folder 1/1
  • ✓ Test directory with test-cases.yml exists 2/2
  • ✓ Status 'active' is valid 1/1
Description (20%) 19/22
Show checks (7)
  • ✓ Contains action verbs: deploys, reviews 4/4
  • ✓ Contains trigger indicators: use when, use for 5/5
  • ✓ Description is specific and actionable 4/4
  • ✗ Skill handles files but description doesn't mention file types 0/3
  • ✓ Description length: 1020/1024 chars 2/2
  • ✓ Has negative triggers (scope boundaries) 2/2
  • ✓ Owner/author specified in metadata 2/2
Instructions (25%) 25/28
Show checks (8)
  • ✓ Skill body has content 3/3
  • ✓ Has step/section structure 4/4
  • ✓ Includes examples 5/5
  • ✓ Includes error handling 4/4
  • ✓ Uses progressive disclosure (references/scripts) 4/4
  • ✓ Actionable language: 4/10 verb patterns found 3/3
  • ✓ Word count: 2481/5000 2/2
  • ✗ Phantom references (files don't exist): references/stack-, scripts/_config.sh, scripts/_config.sh, scripts/verify-local.sh, references/stack- 0/3
Test Coverage (25%) 27/29
Show checks (10)
  • ✓ test-cases.yml exists and parses 3/3
  • ✓ 7 should-trigger tests ✓ 4/4
  • ✓ 4 should-not-trigger tests ✓ 3/3
  • ✓ 6 functional tests ✓ 5/5
  • ✓ 2 negative tests ✓ 3/3
  • ✓ 2 edge case tests ✓ 3/3
  • ✗ Add performance_baseline section comparing with/without skill 0/2
  • ✓ All functional tests have ≥2 assertions 2/2
  • ✓ All trigger phrases are diverse 2/2
  • ✓ All assertions are specific 2/2
Security (15%) 14/15
Show checks (5)
  • ✓ No secrets detected 5/5
  • ✓ No injection vectors in frontmatter 3/3
  • ✓ Name is not reserved 3/3
  • ✓ No suspicious code patterns 2/2
  • ✗ 12 external URLs found — verify they're necessary: https://docs.astral.sh/uv/guides/integration/docker/, https://docs.docker.com/build/building/best-practices/, https://<ui-fqdn 1/2

Test Coverage

7
Should Trigger
4
Should Not Trigger
6
Functional
2
Negative
2
Edge Cases
No
LLM Evals

Velais — Azure infrastructure

The pattern, the conventions and a working template for putting a Velais platform on Azure Container Apps. Every rule here was paid for by a real failure; the reference files name which one.

This skill is project-neutral and cumulative. The rules, the template and the traps apply to every Velais platform on Container Apps. What differs per project — the service set, the data stores, the external systems — lives in a stack profile under references/stack-*.md.

Start by reading the stack profile for the project you are in (see the table below). If none exists yet, the project is new: work from this file and the template, and write the profile as you go (§ How this skill grows).

⚠️ The application code is in scope, and this is where the skill has actually been missed. Paid for on velais-helm, 21 August 2026: a worker was written to run as a Container Apps job, and two of the platform’s constraints were recalled instead of read — one of them wrongly. Both were already written down in references/container-apps-traps.md, by an earlier session, in the exact words that would have prevented the error. The content was not missing; the skill simply never loaded, because every trigger it had named infrastructure and nobody was writing Bicep.

→ So read references/container-apps-traps.md before writing code that will run here, not only before writing the template that deploys it. A scheduler, a timeout, a retry, a health check and a lock are all decided by the platform, and getting them from memory is how a defect ships with a confident comment above it.

Where a live repository disagrees with the template in assets/, the repository is newer and wins — and that disagreement is the signal to update this skill.

Before writing any Bicep

  1. Read references/bicep-patterns.md. It is short, and it is the difference between a template that compiles and one that fails at ARM level for reasons the error does not name.
  2. Derive the deployable set from compose.yaml, plus each service’s config.py for ports and environment variables, plus .env.example. Never from a stale comment or a previous project’s module list.
  3. Read the official documentation for the API version being used. Get versions from the provider, not from a sample: az provider show --namespace Microsoft.App --query "resourceTypes[?resourceType=='containerApps'].apiVersions[0:3]". Prefer the newest stable version; a preview API version in committed infra is a future breakage. If the Bicep CLI reports BCP081 (“no types available”), it is newer than the CLI knows and property validation is silently off — step back one version.

Conventions

  • One Azure resource type per file in modules/. main.bicep is subscription-scoped and creates the resource group; resources.bicep is RG-scoped and wires the modules.
  • tags is a required parameter on every module. Include project, environment and, for anything temporary, lifecycle.
  • @description() on every parameter, @allowed() on constrained ones, @secure() on secrets. A secret never appears in an output — deployment outputs are stored in plaintext in the deployment history.
  • Per-environment .bicepparam, with secrets pulled by readEnvironmentVariable() so nothing is committed. Critical platform passwords have no default and fail closed; optional connector secrets default to not-configured so a first deploy works.
  • The compile must be clean: az bicep build with zero warnings, then az bicep lint. A template that warns is a template people stop reading.
  • Name the subscription; do not inherit it. Assert the expected subscription id and refuse if it differs, rather than switching — resources created in the wrong subscription are found weeks later on someone else’s invoice. Match by id, never by display name.

Naming, and it is a rule because it was four platforms and three conventions

Paid for on velais-helm-agent, 7 September 2026, in a review asked for one resource too late. Four Velais platforms had grown three shapes — vkb-api, atlas-api, ca-helmloop-api-staging — and the environments disagreed with themselves (cae-vkb-staging-uaen-001 carried the instance, cae-helm-staging-uaen did not). Nothing was written down beyond the resource-group pattern, so each platform invented the rest.

Two shapes, and which one applies is decided by the resource, never by taste:

rg-<project>-<env>-<region>-<instance>          the resource group
<abbr>-<project>-<env>-<region>-<instance>      everything infrastructural inside it
<ca|caj>-<project>-<service>-<env>              container apps and jobs
<abbr><project><envShort><uniqueToken>          globally-unique names: st, cr, kv, oai

Container apps are the exception on purpose: the name is capped at 32 characters and it becomes the public FQDN, so it carries the service and drops the region the group already states.

  • Abbreviations come from Microsoft’s CAF list, never from memory. Abbreviation recommendations for Azure resources, and the ones people get wrong are pgsql (not psql), ng (not nat), pep (not pe), caj for a Container Apps job — which is easy to give no prefix at all — and oai for Azure OpenAI.
  • ⚠️ project in tags is the PARAMETER, never a literal. A literal is right on the day it is typed and silently wrong the day the slug changes: it survived a rename on one platform and filed a whole estate under a different product in every cost report and portal filter. Anything that repeats the slug — an action group’s groupShortName, which appears inside the alert mail a human reads at 3am — is the same defect wearing a different hat.
  • A globally-unique name carries uniqueString(resourceGroup().id). Build the whole name and take() it to the cap rather than slicing the token: the compiler can prove the second one.
  • ⚠️ A name a script types is a name that drifts. Every app and job publishes its name or FQDN as a deployment output; a caller reads it from the deployment that made it. This has now been got wrong three times on one platform — and the third time was in a script argument rather than an az containerapp -n, which the test guarding the rule was not reading.

⚠️ Renaming is cheap exactly once: before production exists. A container app cannot be renamed — it is deleted and recreated, with a new FQDN — so every webhook a third party holds, every subscription callback and every DNS record follows. Do the naming review when the second environment is still hypothetical, not when the estate is real.

The deploy is phased, and the order is not negotiable

Each phase needs something the previous one produced. Collapsing them does not work.

  1. Infra without the apps (deployApps=false) — an image cannot be pushed to a registry that does not exist.
  2. Build and push images — retag unchanged services rather than rebuilding. ⚠️ With docker buildx on the runner, not az acr build: ACR Tasks runs the classic builder and rejects every BuildKit feature. See images-and-builds.md.
  3. Deploy the apps (deployApps=true).
  4. Run db-init — the only way to reach a private Postgres, which no laptop can.
  5. Run any data job that needs the schema and the private database — an ingest, a migration backfill. One script takes the job name and a timeout rather than one script per job; the wait, the log dump and the ceiling are identical for all of them.
  6. Run any gate that needs the live data. For a permission-trimmed platform the authorisation gate belongs here and not only in the test suite: a private Postgres and a real embedding model are precisely what a laptop cannot reach, and they are where the failures have been. A non-zero exit fails the deploy — an environment whose perimeter cannot be demonstrated is not one to leave running.
  7. Smoke test, and read the NAT egress IP from the deployment outputs.

Binding a custom domain adds three more steps in the middle, and they have their own ordering trap — see references/custom-domains-and-tls.md.

staging deploys automatically on push; production runs the same flow and pauses on the GitHub environment gate. Adding required reviewers is the only step that must happen in a web UI — with one exception per project that needs one: GitHub has no API for creating a GitHub App, so a platform that reads a private repository as an installation has a second browser step. Document it in the runbook rather than discovering it at the first deploy.

⚠️ A job that quietly falls back is worse than one that fails. If a data job reads its configuration from a shared block, give the jobs the same block the apps get rather than a shorter one. Paid for on the knowledge-base platform: jobEnv carried three variables where sharedEnv carried six, so the ingest job would have run with no embedding endpoint, silently used a local stand-in, and filled the index with vectors that carry no meaning and raise no error.

Secrets: one direction, one source

.env (gitignored)  ──sync-secrets.sh──▶  GitHub Environment secrets
                                              │ readEnvironmentVariable() in .bicepparam
                                              ▼
                                          Key Vault  ──managed identity──▶  Container Apps

The mapping from .env key to GitHub secret name lives in exactly one place, scripts/_config.sh. Never write Key Vault directly — Bicep overwrites it on the next deploy. Never rotate a generated password on re-run: a silent rotation changes the live server and the vault together and breaks a running environment. Only on explicit --rotate. Full detail in references/secrets-and-cicd.md.

Reference files

Read the one that matches the task; do not read them all.

FileWhen
references/bicep-patterns.mdBefore writing Bicep. Secure outputs, KV secret objects, the deployApps toggle, safe dereference, private Postgres, BCP307/BCP318
references/images-and-builds.mdBefore touching a Dockerfile. The uv multi-stage shape, why COPY . . makes the cache worthless, one venv for several services, what must not be in the final image, and the alpine measurement
references/container-apps-traps.mdAnything about runtime behaviour: the 240s ceiling, jobs versus in-process schedulers, Azure Files permissions, scale-to-zero, exec — and application logging, which is a decision to take before the first deploy (which library, the uvicorn traps, correlation, and what never goes in a line)
references/custom-domains-and-tls.mdBinding a hostname, managed certificates, DNS records. Required reading if the app is an MCP server
references/secrets-and-cicd.mdOIDC bootstrap, secret sync, the four sync modes, what CI needs
references/deploy-runbook.mdRunning an actual deploy, first time or otherwise

Stack profiles — read the one for the project you are in, and only that one:

ProfileThe shape it describes
references/stack-knowledge-base.mdThree services, pgvector, MCP, a public hostname
references/stack-lens.mdbruin, rclone, Azure Files, SAP B1, a public SPA
references/stack-helm.mdFastAPI + MCP in front of a customer Odoo, private Postgres, UAE North
references/stack-lens-map.mdA single VM, not ACA — the bwrap-sandboxed agent host (lens-map-skills), where the app needs a kernel feature Container Apps cannot expose

Template

assets/infra-template/ is a working snapshot — copy it to the target repo’s infra/ and adapt. assets/companion-changes/ holds the nginx reverse-proxy files a public SPA needs in order to reach an internal API; they live outside infra/, so they are easy to forget and the application does not work without them.

Adapt these, and search for them by name:

  • main.bicep — the regionAbbreviations map, the project and client tag values.
  • *.bicepparam — location, SKUs, feature flags.
  • scripts/_config.sh — GH_REPO, CICD_APP_NAME, ENVIRONMENTS, SECRET_MAP, and the subscription id.

Validate before deploying

# Every template, not just the entrypoint — main.bicep only compiles what it reaches.
for t in infra/*.bicep infra/modules/*.bicep; do
  az bicep build --file "$t" --stdout > /dev/null   # ZERO OUTPUT, not merely exit 0
  az bicep lint --file "$t"
done
shellcheck --severity=warning infra/scripts/*.sh
hadolint Dockerfile **/Dockerfile
actionlint .github/workflows/*.yml
docker compose up -d && scripts/verify-local.sh   # the stack still runs locally

⚠️ az bicep build exits 0 when it warns, and the warning is usually BCP081 — an api-version the CLI has no types for, which turns property validation off silently and lets a typo reach ARM. Treat any output as a failure. And a loop over a glob must refuse an empty match, or it runs zero times and the job passes green while checking nothing.

And run whatever the image build runs that nothing else does. The local gate exercises the running stack; the image build exercises the build, and the gap between them is where first-deploy failures live. If the project has a frontend, that is npm run build (tsc -b, which neither the dev server nor vitest performs). For a Python service it is the production image build itself — docker compose build with no cache on the service being changed.

The local checks catch more first-deploy failures than the Bicep ones do. Type and build errors otherwise surface twelve minutes into a deploy.

⚠️ Postgres 18 moved its data directory, and a carried-forward compose file exits 1. The official image stores data in a major-version-specific subdirectory from 18 onward, so the volume mounts at /var/lib/postgresql and not /var/lib/postgresql/data. The old path makes the container die at startup with a wall of text about pg_ctlcluster that never says “your mount is one level too deep”. A bare docker run with no volume works fine, which is what makes it look like a compose problem rather than a path problem. Paid for on velais-helm, August 2026.

How this skill grows

This skill is cumulative: every project adds to it, and nothing here is owned by the project that first paid for it. Two kinds of addition, and they go to different places.

A trap, a rule, or a fix that would recur anywhere → the matching references/ file, or this one if it changes the shape of the work. Write it the way the rest is written: the rule first, then the incident that produced it, named. A rule with no failure behind it is a preference, and preferences do not survive contact with the next project.

Anything true only of one platform — its service set, its data stores, its external systems, its per-client steps → a stack profile, references/stack-<project>.md, added to the table above. Never into SKILL.md: the moment a project name is load-bearing in this file, the skill has stopped being usable by the next project.

When the template drifts. assets/infra-template/ is a snapshot, and a live repository that has moved past it is the newer truth. Port the change back deliberately — a module improved on one project is a module improved for all of them — and say in the commit which repository it came from.

One copy only. This skill lives in the velais-skills repository, under skills/velais-azure-infra/, and reaches every machine through the infra plugin, so that every repository sees the same version. Change it there, by pull request. Do not vendor a copy into a project’s .claude/skills/ or keep one in ~/.claude/skills/: two copies fork on the first update, and the fork is discovered at a deploy.