Where credentials live
To run docker push, the pipeline needs a registry password. It cannot go in the workflow file, because that file is in git, and anything ever committed is effectively public forever. Lesson 3-3 kept .env out of images for exactly the same reason.
CI systems solve this with secrets, values stored encrypted in the repo settings, injected into the workflow at run time, and masked as *** if a log tries to print them.
- name: Log in to registry run: docker login ghcr.io -u ada -p "${{ secrets.REGISTRY_TOKEN }}" - run: docker push ghcr.io/ada/shop:${{ github.sha }}
| Property | A committed password | A repo secret |
|---|---|---|
| in git history | yes, forever | never |
| visible in logs | yes | masked |
| rotation | rewrite history | change one setting |
${{ secrets.REGISTRY_TOKEN }} uses the same expression syntax as github.sha in lesson 7-3, and it reads from the encrypted store. The value exists only on the runner, and only during the run.
Environments
Apps also need non-secret config that differs per place they run. Staging, the rehearsal copy of production, points at a test database, while production points at the real one.
The mechanism is the one wired up in lesson 5-2: environment variables, set differently per environment while the image stays identical.
jobs:
deploy:
environment: production
steps:
- run: ./deploy.sh
env:
DB_HOST: ${{ vars.DB_HOST }}| Environment | DB_HOST resolves to |
|---|---|
| staging | the test database host |
| production | the real database host |
GitHub Actions formalizes this with named environments, each carrying its own variables and secrets. Same workflow, same image, and the value changes with the environment the job targets. The next two blocks cover the bash side of this.
Reading config from the environment
export sets an environment variable in the shell, and the script reads whatever the environment provides, exactly how a container or CI step receives its config.
export APP_ENV="production" export PORT="8080" echo "starting app in $APP_ENV mode on port $PORT"
Output
starting app in production mode on port 8080
| Variable | Set by | Read by |
|---|---|---|
APP_ENV | the environment | the app |
PORT | the environment | the app |
The distinction export makes is visibility to child processes. Without it, the variable exists in the current shell only, which is why a script that runs another program must export the values that program needs.
Defaults for missing config
Robust scripts supply a fallback with ${VAR:-default}, so a missing variable does not become an empty string.
echo "db host: ${DB_HOST:-localhost}" export DB_HOST="db.internal" echo "db host: ${DB_HOST:-localhost}"
Output
db host: localhost db host: db.internal
State of DB_HOST | Expansion |
|---|---|
| unset | localhost |
set to db.internal | db.internal |
${DB_HOST:-localhost} expands to the variable when it is set and to the literal text otherwise. The colon in that form also treats an empty value as missing, which is usually what a deploy script wants, since an empty host is no more useful than an absent one.
Why a temporary committed password is not temporary
The objection is that anything committed to git stays in history even after deletion, so the value must be a repo secret referenced as ${{ secrets.NAME }}.
Git never forgets. A later delete leaves the value in history for anyone who ever gets repo access, including every existing clone and fork.
| Storage | Removable later |
|---|---|
committed in ci.yml | only by rewriting history, and clones keep it |
| repo secret | yes, one settings change |
Secret stores keep the value encrypted, out of history, injected only at run time, and masked in logs. Temporarily committed credentials are how real breaches start, and the correct response to the discovery is always to rotate the credential rather than to delete the line.
What the encrypted store holds
They are secrets, read in a workflow with ${{ secrets.REGISTRY_TOKEN }}.
They live encrypted in the repository or environment settings, never in git history, and the runner masks them as *** if a log tries to print one.
| Value | Belongs in secrets |
|---|---|
| registry token | yes |
| deploy key | yes |
| third-party API key | yes |
| a database hostname | no, that is a plain variable |
Anything that grants access belongs there, and rotating one is a single settings change instead of a git-history scrub. Non-sensitive config stays in variables, which keeps the secret list short enough to audit.