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.

EnvironmentTypical triggerProtection
PreviewEvery pull requestNone beyond scoped credentials
StagingMerge to mainAutomatic, no approval required
ProductionTag or manual dispatchRequired 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 echo a 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_TOKEN permissions at their broad default instead of declaring an explicit, minimal permissions block.
  • Referencing third-party actions by a floating major-version tag on any workflow with access to production secrets.
  • Requiring no approval on the production environment, 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, and production are configured as separate GitHub Environments with separate secrets.
  • production requires at least one manual reviewer before deploying.
  • Every workflow declares an explicit, minimal permissions block.
  • 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.