Every part of this series so far has added capability: a reproducible workspace, a review process, automated checks, previews, and safe migrations. Each addition also expanded what the pipeline is trusted to do. By this point, a merge to main can build an image, run migrations against a real database, and deploy to a live environment. That is exactly the kind of access an attacker wants, and exactly the kind of access a tired developer can misuse by accident with one wrong workflow edit. This part is about keeping that trust narrow, visible, and revocable.
Why deployment security is a workflow design problem
Deployment credentials rarely leak through a dramatic breach. They leak through a workflow that runs untrusted code with too much access, a secret pasted into a log by an overly verbose command, or a long-lived token that was never rotated because nobody remembered it existed. None of that requires an attacker to be sophisticated. It requires the pipeline to be more permissive than the task in front of it.
The design principle underneath everything in this article is the same one that governs the rest of the series: give each part of the system exactly the access it needs to do its specific job, and nothing else.
GitHub Environments as a protection boundary
GitHub's environments feature lets deployment jobs target a named environment—preview, staging, production—each with its own secrets and its own protection rules, layered on top of the branch protection covered in part four.
# .github/workflows/deploy-production.yml name: Deploy Production on: push: tags: ["v*"] jobs: deploy: runs-on: ubuntu-latest environment: name: production url: https://app.example.com permissions: contents: read deployments: write steps: - uses: actions/checkout@v4 - uses: superfly/flyctl-actions/setup-flyctl@1.5 - name: Deploy env: FLY_API_TOKEN: ${{ secrets.FLY_PRODUCTION_TOKEN }} run: flyctl deploy --config fly.toml --strategy rolling
Configuring production as a protected environment in the repository settings can require a manual approval before the job runs, restrict which branches or tags may deploy to it, and add a wait timer. A staging environment usually needs none of that friction—it should deploy automatically on every merge to main—while production benefits from at least one required reviewer, so a deploy is always a deliberate, witnessed action rather than a side effect of a merge.
| Environment | Typical trigger | Protection |
|---|---|---|
| Preview | Every pull request | None beyond scoped credentials |
| Staging | Merge to main | Automatic, no approval required |
| Production | Tag or manual dispatch | Required reviewer, restricted branches/tags |
Least-privilege permissions for the GITHUB_TOKEN
The token GitHub Actions injects automatically defaults, in many repository configurations, to broad read/write access across the repository. Most jobs need almost none of that. Setting an explicit, minimal permissions block—both at the workflow level and, where they differ, at the job level—shrinks what a compromised or misconfigured step can actually do.
permissions: contents: read
A job that only needs to check out code and run tests should never hold write access to issues, packages, or deployments. Grant the wider scopes (deployments: write, pull-requests: write for the preview-link comment in part six) only on the specific jobs that need them, not globally at the top of the file.
Storing and scoping secrets correctly
Secrets belong at the narrowest scope that can still serve their purpose. A token that only ever deploys previews should live in the preview environment's secrets, not in repository-wide secrets available to every workflow. This is the same separation introduced informally in part six's FLY_PREVIEW_TOKEN—made deliberate here: preview, staging, and production each get their own credential, so a leak in one environment cannot reach the others.
A few habits reduce leak surface regardless of scope:
- Never
echoa secret, even for debugging—GitHub redacts known secret values in logs, but only the exact string, not a transformed or partial version of it. - Avoid passing secrets as command-line arguments where possible; a process's argument list can be visible to other processes on the same runner.
- Rotate deploy tokens on a schedule, not only after a suspected incident.
- Remove a contributor's access to secrets the moment their role no longer needs it—scope decays, it does not maintain itself.
Pinning actions to a commit SHA
A third-party action referenced by a mutable tag (@v4) can change behavior—or be compromised—without the workflow file changing at all. Pinning to a full commit SHA removes that ambiguity:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
This is more maintenance than trusting a floating tag, and it is worth it specifically for actions with write access to secrets or deployment credentials. Dependabot can be configured to open pull requests that bump pinned SHAs, which keeps the maintenance cost close to what floating tags would have cost anyway, while preserving the ability to review exactly what changed before it runs with production access.
OIDC instead of long-lived cloud keys
Where the deployment target supports it, OpenID Connect lets a GitHub Actions job request a short-lived, scoped credential at run time instead of reading a long-lived secret from storage. AWS, GCP, and Azure all support federating trust to a specific repository and workflow, so the cloud provider verifies the job's identity directly rather than trusting whatever static key happens to be stored in GitHub.
permissions: id-token: write contents: read steps: - uses: aws-actions/configure-aws-credentials@v4 with: role-to-assume: arn:aws:iam::123456789012:role/gh-actions-deploy aws-region: us-east-1
Not every platform offers this yet. Fly.io, for example, is authenticated with a scoped deploy token rather than an OIDC exchange, which means the token-hygiene practices above—narrow scope per environment, scheduled rotation, no cross-environment reuse—carry more of the security weight there than they would against a provider with full OIDC support. The principle survives the platform gap: prefer the shortest-lived, narrowest-scoped credential the platform allows, and treat any long-lived token as a liability to be minimized, not a convenience to be relied on indefinitely.
Common mistakes
- Storing a single deploy token as a repository secret and using it for preview, staging, and production alike.
- Leaving the default
GITHUB_TOKENpermissions at their broad default instead of declaring an explicit, minimalpermissionsblock. - Referencing third-party actions by a floating major-version tag on any workflow with access to production secrets.
- Requiring no approval on the
productionenvironment, so a deploy happens as a side effect of a merge rather than a deliberate action. - Treating "we rotated the token once, after the last incident" as an ongoing practice instead of scheduling rotation proactively.
Practical checklist
-
preview,staging, andproductionare configured as separate GitHub Environments with separate secrets. -
productionrequires at least one manual reviewer before deploying. - Every workflow declares an explicit, minimal
permissionsblock. - Actions with access to secrets are pinned to a commit SHA, kept current via Dependabot.
- OIDC federation is used wherever the deployment target supports it.
- Any long-lived deploy token is scoped narrowly and rotated on a schedule, not only after an incident.
- No secret is ever echoed to a log or passed as a bare command-line argument.
Previous: Database Migrations Without Panic: Local → Preview → Staging → Production
Next: Staging and Production on Fly.io: A Practical Release Path. With credentials properly scoped, the series closes the loop with a concrete deployment target: separate environments, health checks, and rollback.

