Configuration separation
- Lesson 7
- 40 min
- Module 3: Manage environments and secrets
Lesson 7 of 15 · CI/CD, Testing and Safe ReleasesConfiguration separation
1. Understand the delivery path
2. Create a repeatable CI pipeline
3. Manage environments and secrets
4. Release AI and database changes
5. Recover and maintain the release process
What you will be able to do
Run the image that CI tested in two places, staging and production, with different settings. Then nobody can say “it worked in staging” about a different build.
Before you startWhat to know and to have ready
What you need to know
- The digest, and why a tag can move, from Registry and deployment: Registry, repository and tag.
- Settings at run time and secret files, from Runtime configuration: One image, different settings and Secret files.
- Compose projects and volumes, from Persistent data: Write compose.yaml.
- How CI builds the image once and records its digest in
build-info.json, from Build and test: Build once, then keep that build and Publish evidence: build-info.json: digest, commit and run in one file.
What you need on your computer
- Your public repository
github.com/<you>/ticket-api-ciwith.github/workflows/ci.yml, from Publish evidence: Guided practice: keep the evidence, and the ruleset onmain, from Publish evidence: Protect main with a ruleset. - One green CI run on
mainthat pushed an image toghcr.io/<you>/ticket-api-ci, from the same guided practice. Write down its digest from the run’sbuild-info.json. - Docker Desktop (or Docker Engine on Linux), from Install and verify: Install Docker in Docker and Cloud Essentials for AI Developers.
- Live route only: an Azure subscription and the Azure CLI with the
containerappextension, as in Registry and deployment: The live route. Simulated route: nothing more. - If your project is missing or broken, copy the snapshot
end-of-m02, as in Use a snapshot. A snapshot cannot hold GitHub settings: make the ruleset onmainagain.
Run with GitHub Actions (ubuntu-24.04 runners) in the public repository Nextia-Intelligence/ticket-api-ci, Docker Desktop 4.81.0 (Engine 29.6.1, Compose 5.2.0) on macOS (Apple silicon), and Azure CLI 2.88.0 in canadacentral. The Windows and Linux steps are not tested.
Two routes
Section titled “Two routes”This module has two routes, as in the Docker course. Choose one, and use it in all three lessons. You can also do both.
| Live | Simulated | |
|---|---|---|
| Where staging and production run | Two Azure container apps, ticket-api-staging and ticket-api-production |
Two Compose projects on your computer, ticket-api-staging and ticket-api-production |
| Who deploys | The Deploy workflow in GitHub Actions | You, with scripts/promote-local.sh |
| Cost | Practically zero, if you delete it on the same day | Free |
| You need | An Azure subscription | Only Docker |
Every output has a label. Live outputs come from the real runs in Nextia-Intelligence/ticket-api-ci. Simulated outputs come from Docker on a Mac on the same day. The Azure resources of the test run no longer exist, so their addresses do not answer now.
One image, many environments
Section titled “One image, many environments”Amira’s CI run on main built one image and tested it. Its digest is sha256:4eb3db8d…. The help desk wants to try the API before customers get it. The team wants /docs there, so that testers can send requests in the browser. Production must hide /docs.
Sam asks: “Can CI build a second image for staging, with the docs on?” Amira says no. A second build is a second artifact. Nobody tested it, and nobody can prove that it has the same code and the same packages. So she keeps one artifact (the built and tested result of the pipeline, here the image) and changes only its settings.
An environment is one place where the application runs, with its own settings, secrets, access and data. This course has two:
- Staging is a copy of production for checks. The team tries a new version there before users see it.
- Production is the environment that users and other systems call.
The image in staging is a release candidate: a build that can become the release, if every check passes. When it reaches production, nothing in it changes. Only the environment around it is different.
| Must be the same in every environment | May differ, and must be outside the image |
|---|---|
| The code | The settings: SHOW_DOCS, CLASSIFIER_VERSION, the app name, the address |
The packages (requirements-run.txt) |
The secrets: each environment has its own API key |
| The base image and the Dockerfile | The identity that deploys it, and what that identity may change |
| The image digest | The data store: each environment has its own database, or none |
The rule is simple: if a difference is in the right column, put it in a setting. If someone wants a difference in the left column, it is a new version, and it goes through CI again.
Text description of the diagram
The digest at the top is the only thing that the two environments share. Each column is a boundary. Staging’s settings, key and identity cannot reach production’s app, and the other way round. Production has one more rule than staging: a person must approve before its job starts. This lesson makes the settings. Credentials and access makes the identities and the GitHub environments. Promote to staging moves the digest from left to right.
Where the settings live
Section titled “Where the settings live”The settings must live somewhere that the image does not. Each route has its own place for them.
Live: the container app holds its settings and its secret. You set them when you create the app, and the pipeline changes only the image. A deployment with az containerapp update --image keeps every other setting of the app.
Simulated: the project holds one settings file per environment, and one git-ignored key file per environment:
# Settings of the local staging environment. No secrets here.ENVIRONMENT=stagingPORT=8081SHOW_DOCS=trueCLASSIFIER_VERSION=1.1# Settings of the local production environment. No secrets here.ENVIRONMENT=productionPORT=8082SHOW_DOCS=falseCLASSIFIER_VERSION=1.0The settings files are in Git: they hold no secret, and a reviewer must see every change to them. The keys are in secrets/staging_api_key.txt and secrets/production_api_key.txt, and secrets/ is in .gitignore. The database password is in .env, which is git-ignored too.
CLASSIFIER_VERSION is there for Module 4. Version 1.1.0 of the API does not read it yet. You see what that means in the worked example.
One Compose file describes one environment. The environment’s name comes from the settings file:
# One environment (staging or production) on your computer: the API and its# own database. The image is given by digest, so every# environment runs exactly the same build. Start it with scripts/promote-local.sh.name: ticket-api-${ENVIRONMENT:?Set ENVIRONMENT}
services: api: image: ${IMAGE:?Set IMAGE to the image name with its digest} ports: - "127.0.0.1:${PORT:?Set PORT}:8000" environment: API_KEY_FILE: /run/secrets/api_key REQUIRE_API_KEY: "true" DATABASE_URL: postgresql://tickets:${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}@db:5432/tickets SHOW_DOCS: ${SHOW_DOCS:-false} CLASSIFIER_VERSION: ${CLASSIFIER_VERSION:-1.0} secrets: - api_key healthcheck: test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=2)"] interval: 5s timeout: 3s retries: 5 depends_on: db: condition: service_healthy
db: image: postgres:18.6-trixie environment: POSTGRES_USER: tickets POSTGRES_DB: tickets POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} volumes: - pgdata:/var/lib/postgresql healthcheck: test: ["CMD", "pg_isready", "-U", "tickets", "-d", "tickets"] interval: 2s timeout: 3s retries: 15
secrets: api_key: file: ../../secrets/${ENVIRONMENT}_api_key.txt
volumes: pgdata:Four lines do the separation:
| Line | What it does |
|---|---|
name: ticket-api-${ENVIRONMENT…} |
Each environment is its own Compose project. Its containers, network and volume names start with ticket-api-staging or ticket-api-production. |
image: ${IMAGE…} |
The image comes from outside, by digest. The file has no image of its own, so it cannot run a different build by accident. |
file: ../../secrets/${ENVIRONMENT}_api_key.txt |
Each environment reads its own key. |
pgdata: |
Each project gets its own volume, so each environment has its own database. |
:? makes a missing value an error, not an empty string. If you forget the settings file, Compose stops with Set ENVIRONMENT, and nothing starts.
This is the file of the reference repository’s pull request #1. It has one gap on Apple silicon, which Promote to staging shows and fixes.
Separate data stores
Section titled “Separate data stores”Staging and production never share a database. A tester’s tickets in staging must not appear in production’s history. A migration that fails in staging must not touch production’s rows. In Module 4, each environment runs the migrations on its own database for this reason.
- Simulated: each Compose project has its own PostgreSQL container and its own volume,
ticket-api-staging_pgdataandticket-api-production_pgdata. - Live: the course has no database in the cloud, to keep the cost at zero. Both apps run without
DATABASE_URL, so history is off, and/v1/historyanswers503by design. If you add a database later, give each environment its own server or its own database, with its own password.
Text description of the diagram
The simulated route keeps every boundary that you can have on one computer. Each environment has its own project, port, settings file, key and database. The image is the same digest as in the cloud. What it cannot simulate: the identities, the HTTPS address, and the approval by another person. In the simulation, you are the reviewer, and your second command is the approval.
Worked example (live): two apps, one image
Section titled “Worked example (live): two apps, one image”Amira makes the resource group and the environment, as in the Docker course, with the course’s names:
az group create --name rg-ticket-api-ci --location canadacentral --tags purpose=practice course=C19 -o tableaz containerapp env create --resource-group rg-ticket-api-ci --name cae-ticket-api-ci --location canadacentral -o tableThe output (live, shortened):
Location Name------------- ----------------canadacentral rg-ticket-api-ci
WARNING: No Log Analytics workspace provided.WARNING: Generating a Log Analytics workspace with name "workspace-rgticketapiciPwmn"Container Apps environment created. To deploy a container app, use: az containerapp create --help
Location Name ResourceGroup-------------- ----------------- ----------------Canada Central cae-ticket-api-ci rg-ticket-api-ciThen she makes the two apps from the same image. Only --name, the key and SHOW_DOCS differ. The image is the digest of the first green CI run on main (commit c862935). There is no registry in Azure: the image is in GitHub Container Registry, and the package is public, so Azure pulls it with no password.
IMAGE=ghcr.io/usmanahmed99/ticket-api-ci@sha256:6c9bb4ed7c7cffc27f0c5489ba867d4d5db16def1c59c8bcbaeb3d26c45ce2e1STAGING_KEY=$(python3 -c "import secrets; print(secrets.token_urlsafe(24))")az containerapp create --resource-group rg-ticket-api-ci --name ticket-api-staging --environment cae-ticket-api-ci \ --image "$IMAGE" --ingress external --target-port 8000 \ --cpu 0.25 --memory 0.5Gi --min-replicas 0 --max-replicas 2 \ --secrets api-key="$STAGING_KEY" \ --env-vars API_KEY=secretref:api-key REQUIRE_API_KEY=true SHOW_DOCS=true$IMAGE = "ghcr.io/usmanahmed99/ticket-api-ci@sha256:6c9bb4ed7c7cffc27f0c5489ba867d4d5db16def1c59c8bcbaeb3d26c45ce2e1"$STAGING_KEY = python -c "import secrets; print(secrets.token_urlsafe(24))"az containerapp create --resource-group rg-ticket-api-ci --name ticket-api-staging --environment cae-ticket-api-ci ` --image $IMAGE --ingress external --target-port 8000 ` --cpu 0.25 --memory 0.5Gi --min-replicas 0 --max-replicas 2 ` --secrets "api-key=$STAGING_KEY" ` --env-vars API_KEY=secretref:api-key REQUIRE_API_KEY=true SHOW_DOCS=trueUse your own image name and digest. The output (live, shortened):
Container app created. Access your app at https://ticket-api-staging.blackhill-8c0e6b69.canadacentral.azurecontainerapps.io/
Fqdn Revision------------------------------------------------------------------------- ---------------------------ticket-api-staging.blackhill-8c0e6b69.canadacentral.azurecontainerapps.io ticket-api-staging--cbaey1qFor production, she runs the same command with three changes: --name ticket-api-production, a new key in PRODUCTION_KEY, and SHOW_DOCS=false. The output (live, shortened):
Container app created. Access your app at https://ticket-api-production.blackhill-8c0e6b69.canadacentral.azurecontainerapps.io/
Fqdn Revision---------------------------------------------------------------------------- ------------------------------ticket-api-production.blackhill-8c0e6b69.canadacentral.azurecontainerapps.io ticket-api-production--hx6e7q9Keep the two keys in the terminal (or in a password manager) until Credentials and access. There, each key goes into its GitHub environment as SMOKE_API_KEY, so that the pipeline’s smoke check can call the app.
The proof came later in the day. After release 1.1.0, both apps ran the image ghcr.io/usmanahmed99/ticket-api-ci@sha256:4eb3db8d…. The deploy log of run 37665743749 shows the same image line in both jobs (live):
staging / Deploy to staging | IMAGE: ghcr.io/usmanahmed99/ticket-api-ci@sha256:4eb3db8d4ea249fb9b44b27d96deb68aba6da3e3fbc9cf32311a9cbadf4d857aproduction / Deploy to production | IMAGE: ghcr.io/usmanahmed99/ticket-api-ci@sha256:4eb3db8d4ea249fb9b44b27d96deb68aba6da3e3fbc9cf32311a9cbadf4d857aAnd /docs answered 200 in staging and 404 in production (live). Same bytes, different behaviour, because of one setting.
Worked example (simulated): compare the two environments
Section titled “Worked example (simulated): compare the two environments”Before she starts anything, Amira asks Compose what each environment would run. docker compose config reads the Compose file and the settings files, and prints the result with every variable filled in. It starts nothing.
export IMAGE=ghcr.io/usmanahmed99/ticket-api-ci@sha256:4eb3db8d4ea249fb9b44b27d96deb68aba6da3e3fbc9cf32311a9cbadf4d857adocker compose -f deploy/local/compose.yaml --env-file deploy/local/staging.env --env-file .env config > reports/staging-config.yamldocker compose -f deploy/local/compose.yaml --env-file deploy/local/production.env --env-file .env config > reports/production-config.yamldiff reports/staging-config.yaml reports/production-config.yaml$env:IMAGE = "ghcr.io/usmanahmed99/ticket-api-ci@sha256:4eb3db8d4ea249fb9b44b27d96deb68aba6da3e3fbc9cf32311a9cbadf4d857a"docker compose -f deploy/local/compose.yaml --env-file deploy/local/staging.env --env-file .env config > reports/staging-config.yamldocker compose -f deploy/local/compose.yaml --env-file deploy/local/production.env --env-file .env config > reports/production-config.yamlCompare-Object (Get-Content reports/staging-config.yaml) (Get-Content reports/production-config.yaml)Predict first: which lines will diff print? Will the image: line be one of them?
The output (simulated; the secret file paths shortened):
1c1< name: ticket-api-staging---> name: ticket-api-production10c10< CLASSIFIER_VERSION: "1.1"---> CLASSIFIER_VERSION: "1.0"13c13< SHOW_DOCS: "true"---> SHOW_DOCS: "false"30c30< published: "8081"---> published: "8082"61c61< name: ticket-api-staging_default---> name: ticket-api-production_default64c64< name: ticket-api-staging_pgdata---> name: ticket-api-production_pgdata67,68c67,68< name: ticket-api-staging_api_key< file: .../ticket-api-ci/secrets/staging_api_key.txt---> name: ticket-api-production_api_key> file: .../ticket-api-ci/secrets/production_api_key.txtEvery difference is in the right column of the table: the name, two settings, the port, the network, the volume and the key. The image: line is not in the list, because it is the same in both files:
image: ghcr.io/usmanahmed99/ticket-api-ci@sha256:4eb3db8d4ea249fb9b44b27d96deb68aba6da3e3fbc9cf32311a9cbadf4d857aThis is a check that you can repeat before every deployment: compare what will run, and look for a difference in the left column.
Later, in Promote to staging, Amira starts both projects and calls them. Two results matter here (simulated):
/docsanswered200on port 8081 and404on port 8082, as in the cloud.POST /v1/classifywith “Package never arrived” gave{"category":"other","priority":3,"confidence":0.2,"model_version":"keywords-1.0"}in both environments, although staging hasCLASSIFIER_VERSION=1.1.
The second result is not a mistake in the setup. Version 1.1.0 of the code does not read CLASSIFIER_VERSION. A setting that the code ignores changes nothing, and no error tells you. That is why you check a setting by its effect (here /docs), not by its presence in a file. Module 4 adds the code that reads the flag, and then the same files give keywords-1.1 in staging.
Guided practice: the local environment files
Section titled “Guided practice: the local environment files”Work in your project folder, on a new branch: git switch -c feature/deploy. The branch merges into main by pull request in Promote to staging: Guided practice: promote your own digest.
-
Make the folder
deploy/local, and writedeploy/local/compose.yaml,staging.envandproduction.envas above. -
If
.envdoes not exist, copy.env.exampleto.env, and change the password in it. -
Make the two keys. Each one is new, and only for that environment:
Terminal mkdir -p secrets reportspython3 -c "import secrets; print(secrets.token_urlsafe(24), end='')" > secrets/staging_api_key.txtpython3 -c "import secrets; print(secrets.token_urlsafe(24), end='')" > secrets/production_api_key.txtPowerShell (not tested) New-Item -ItemType Directory -Force secrets, reports | Out-Nullpython -c "import secrets; print(secrets.token_urlsafe(24), end='')" | Set-Content -NoNewline secrets/staging_api_key.txtpython -c "import secrets; print(secrets.token_urlsafe(24), end='')" | Set-Content -NoNewline secrets/production_api_key.txt -
Check that Git does not see the keys:
git status --short. Expected: Git showsdeploy/, andsecrets/,reports/and.envare not. -
Set
IMAGEto your own image with the digest frombuild-info.json, as in the worked example. -
Write both configurations into
reports/, and compare them withdiff(orCompare-Object). -
Find the
image:line in both files. Expected: the same digest, letter for letter. -
Commit the three files in
deploy/local/:git add deploy/localandgit commit -m "Local staging and production environments".
Show the expected result
diff lists only the project name, CLASSIFIER_VERSION, SHOW_DOCS, the port (8081 and 8082), the network, the volume and the key file, as in the worked example. Both files have the same image: line with your digest. git status --short shows ?? deploy/ before the commit, and nothing about secrets/, reports/ or .env. After step 8, git log --oneline -1 shows your commit on feature/deploy.
Your turn: a third environment
Section titled “Your turn: a third environment”The help desk lead asks for a training environment, training, where new staff can try the API with the docs on. Write deploy/local/training.env with PORT=8083. Do not change compose.yaml.
Before you run docker compose config with the new file, predict:
- the project name,
- the name of its volume,
- the key file that it needs.
Then run it, and check your answers. What else must you make before it can start?
Show the answer
ENVIRONMENT=training gives the project ticket-api-training, the volume ticket-api-training_pgdata and the key file secrets/training_api_key.txt. A test with a third file printed the matching names, and published: "8083". docker compose config does not check that the key file exists, so you must make secrets/training_api_key.txt yourself, with a new key. The new environment needed no change to the code, the image or the Compose file: only a settings file and a key. That is the sign of good configuration separation.
A second build for the release
Section titled “A second build for the release”Common mistake
The tag that started a new build
Symptom: the first version of the reference repository’s ci.yml also ran on version tags. A push of the tag v1.1.0 would start the whole CI workflow again, and the image job would build a new image for the tag. Staging had checked the image of the main run, not this one. Nothing in the pipeline could prove that the two builds were the same: a base image or a package can change between two builds.
Cause: the pipeline treated “make a release” as “build”. This is the most common mistake in delivery pipelines: a new artifact for each environment or each step.
Fix: a tag must not build. Pull request #2 removed the tag trigger from ci.yml:
push: branches: [main, "hotfix/**"] tags: ["v*"]and added release.yml, which gives the existing image its version tag. The test run measured the result: the tag 1.1.0 and the tag sha-92fff66 have the same digest, sha256:4eb3db8d…. Promote to staging shows the workflow.
For a real AI service: an image often holds a model file or downloads one at build time. A second build can pick up a different model, and its predictions differ from the ones that staging checked. Promote one artifact by digest, and the predictions in production are the ones that you tested.
Check your understanding
Sam wants /docs in staging and asks CI to build a staging image with SHOW_DOCS set to true inside it. What do you answer, and what do you do instead?
Show the answer
A second image is a second artifact. Nobody tested it, and nobody can prove that it has the same code and packages. Keep one image, and put the difference in a setting outside the image: SHOW_DOCS=true in staging (the container app’s environment variable, or deploy/local/staging.env) and false in production. Then both environments run the same digest, and the check in staging is a check of the production build.
Does your answer have the main points of ours?
Sign in and enroll in the course to save your answers. Every check counts toward the certificate.
Check your understanding
Predict: you set CLASSIFIER_VERSION=1.1 in staging.env and start version 1.1.0 of the API. Which model_version does staging report, and why?
Show the answer
keywords-1.0, the same as production. Version 1.1.0 of the code does not read CLASSIFIER_VERSION, so the setting has no effect, and nothing reports an error. Check a setting by its effect, for example /docs (200 in staging, 404 in production), not by its presence in a file. The flag works from the version that reads it (Module 4).
Does your answer have the main points of ours?
Sign in and enroll in the course to save your answers. Every check counts toward the certificate.
Check your understanding
Name two things that must be the same in staging and production, and two that must differ. Why must the data store differ?
Show the answer
The same: the image digest and the code (also the packages and the Dockerfile, which are inside the image). Different: the settings (such as SHOW_DOCS) and the secrets (each environment’s own key), also the identity and the data store. The data store must differ, so that test data from staging never appears in production. Also, a migration or a mistake in staging then cannot change production’s records.
Does your answer have the main points of ours?
Sign in and enroll in the course to save your answers. Every check counts toward the certificate.
Recap
- One artifact, many environments: staging and production run the same image digest. Only settings, secrets, identities and data stores differ.
- Live: each container app holds its own settings and secret. Simulated:
deploy/local/<environment>.envand the git-ignoredsecrets/<environment>_api_key.txt. - One Compose file with
name: ticket-api-${ENVIRONMENT}gives each environment its own project, port, key and database. docker compose configanddiffshow what differs before anything runs. Theimage:line must be the same.- Check a setting by its effect. Version 1.1.0 ignores
CLASSIFIER_VERSION. - A release must not build again: a tag that triggers a second build makes an artifact that nobody tested.
The apps exist, but nobody can deploy to them yet without your own Azure sign-in. The next lesson gives each environment an identity that needs no password and can change only its own app.
Next: Credentials and access