Deployment Jobs on GitHub Actions
A deploy job needs one environment, a fixed concurrency group with cancel-in-progress off, an OIDC credential exchange, and an artifact pinned by digest.
A deployment job on GitHub Actions is a job that ships an artifact that something else already built, and its shape comes down to four keys: environment to carry the protection rules, concurrency with a fixed group name and cancel-in-progress left at false, permissions: id-token: write to exchange a workflow token for short-lived cloud credentials, and a needs output holding the digest of the thing being deployed. Everything that produces bytes, including compiles, image builds, and registry pushes, sits in jobs upstream of the gate, so the artifact an approver releases is the artifact that was tested.
This guide covers how to tell a deploy job that carries too much from one that carries the right amount, the four keys that fix it, a full workflow with an OIDC credential exchange and a digest pull, and a time model for a deploy job whose minutes are dominated by artifact transfer.
Diagnosis
Read the deploy job's step list before reading anything else. Five shapes account for most of the trouble, and each one puts time or risk in a different place.
| What the deploy job does | What it means | Where the cost lands |
|---|---|---|
Runs docker build or a compile step after the gate | The approver released a commit, and the job produced a fresh artifact from it | Build minutes billed behind an approval, and a binary nobody tested |
| Pulls by tag rather than by digest | A moving tag can resolve to different bytes between the test job and the deploy job | Silent drift between environments |
| Holds a long-lived cloud access key in a repository secret | The credential outlives the run and every job that can read the secret can use it | Blast radius, and a rotation task nobody owns |
Shares a concurrency group with the checks workflow | GitHub resolves group names across the repository, so a test run and a release occupy one lane | A release parked behind a test run |
Sets cancel-in-progress: true on the deploy lane | The next merge stops an apply where it stands | A half-applied target and manual reconciliation |
The first row is the one that hides. A gate exists so a person can look at what is about to ship, and a build step after the gate means the bytes that ship were produced after the person looked. The build once and deploy many pattern is the structural answer, and this page covers the job on the receiving end of it.
The last two rows look identical in the run view: a deploy sitting in a waiting state. Which hold you are looking at decides the fix, and the mapping from symptom to mechanism is in how to stop a workflow from blocking deploys.
One thing that is rarely the problem: platform capacity. Run as many jobs as your workflows need. Generally available Linux and Windows runners do not have plan-level concurrency caps. Capacity adjusts dynamically, so serializing the deploy lane is a decision you write into the YAML rather than a limit you inherit.
Fix
Bind the job to an environment
The environment key is what turns an ordinary job into a gated one. A job that references an environment stays in a waiting state until every protection rule on that environment passes, and GitHub dispatches it to a runner only after that (deployments and environments reference, checked on 2026-08-13). Required reviewers accept up to 6 users or teams and one approval releases the job. A wait timer accepts 1 to 43,200 minutes. Deployment branch and tag rules match against GITHUB_REF, which keeps a fork or a feature branch from opening a production deployment at all.
Because those holds happen before dispatch, the approval window is unbilled. The full rule table sits in the deployment environment glossary entry.
Give the deploy lane its own concurrency group
Name the group for the target, not for the branch. group: deploy-production resolves to the same string on every merge, so releases line up in order, and cancel-in-progress: false keeps a running apply alive when the next merge arrives. GitHub matches the resolved group string across the repository rather than per workflow file (workflow syntax reference, checked on 2026-08-13), which is why a checks workflow keyed on github.ref can share a lane with a release.
Exchange a token for short-lived credentials
Request id-token: write in the job's permissions block, then trade the workflow's OIDC token for cloud credentials scoped to that repository, branch, and environment. The subject claim can be pinned to repo:owner/name:environment:production, so a credential minted for a production deploy is unusable from a pull request run. Nothing long lived sits in a repository secret afterwards.
The runner side of that story matters for a job that briefly holds production credentials. Each WarpBuild runner runs in its own virtual machine, created on demand and destroyed after the build, and build secrets stay in your repository rather than with the runner provider (security documentation).
Pull the artifact by digest
Take the digest as a job output from the build and use it verbatim: registry/api@sha256:... resolves to one immutable manifest. Workflow artifacts get the same treatment through actions/download-artifact with an explicit name, and an explicit retention-days on the upload side keeps the storage line predictable.
Size the job for the bytes, then stop
Rates and shapes below come from the cloud runners documentation and the pricing page, checked on 2026-08-13.
| runs-on label | OS | vCPU | RAM | Storage | Rate per minute |
|---|---|---|---|---|---|
| warp-ubuntu-latest-x64-2x | Ubuntu 24.04 | 2 | 8 GB | 150GB SSD | $0.004 |
| warp-ubuntu-latest-x64-4x | Ubuntu 24.04 | 4 | 16 GB | 150GB SSD | $0.008 |
| warp-ubuntu-latest-x64-8x | Ubuntu 24.04 | 8 | 32 GB | 150GB SSD | $0.016 |
| warp-ubuntu-latest-x64-16x | Ubuntu 24.04 | 16 | 64 GB | 150GB SSD | $0.032 |
A release job that signs and uploads a mobile build runs on a macOS label while the service deploy stays on Linux, both gated by the same environment. Ordering these levers against the rest of a pipeline is covered in the guide to speeding up GitHub Actions.
Configuration
The build job pushes once and exports the digest. The deploy job consumes it, and does nothing else that produces bytes.
name: release
on:
push:
branches: [main]
concurrency:
group: deploy-production
cancel-in-progress: false
jobs:
build:
runs-on: warp-ubuntu-latest-x64-8x
permissions:
contents: read
id-token: write
outputs:
digest: ${{ steps.push.outputs.digest }}
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::111122223333:role/gha-build
aws-region: us-east-1
- uses: aws-actions/amazon-ecr-login@v2
id: ecr
- uses: Warpbuilds/build-push-action@v6
id: push
with:
context: .
push: true
profile-name: api-builder
tags: ${{ steps.ecr.outputs.registry }}/api:${{ github.sha }}
- uses: actions/upload-artifact@v4
with:
name: migrations
path: db/migrations/
retention-days: 14
deploy:
needs: build
runs-on: warp-ubuntu-latest-x64-4x
environment:
name: production
url: https://app.example.com
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v4
with:
sparse-checkout: deploy/
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::111122223333:role/gha-deploy-production
aws-region: us-east-1
- uses: aws-actions/amazon-ecr-login@v2
id: ecr
- uses: actions/download-artifact@v4
with:
name: migrations
path: db/migrations/
- name: Pull the exact image that was tested
run: docker pull "${{ steps.ecr.outputs.registry }}/api@${{ needs.build.outputs.digest }}"
- name: Apply
run: ./deploy/apply.sh "${{ needs.build.outputs.digest }}"
- name: Smoke test
run: ./deploy/smoke.sh https://app.example.comFive details carry the behavior. The concurrency block sits at workflow level with a fixed group name, so every merge to main queues in one release lane. environment: production holds the deploy job before dispatch, and the url field populates the deployments view. Each job requests its own id-token: write and assumes a different role, so the build role can push to the registry while the deploy role can reach production. The pull uses @${{ needs.build.outputs.digest }} rather than a tag. And the checkout is sparse, because the deploy job needs the scripts under deploy/ rather than the whole tree.
Adding a second target is one more job with its own environment and its own role, promoting the same digest:
deploy-eu:
needs: [build, deploy]
runs-on: warp-ubuntu-latest-x64-4x
environment:
name: production-eu
url: https://eu.app.example.com
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v4
with:
sparse-checkout: deploy/
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::111122223333:role/gha-deploy-eu
aws-region: eu-west-1
- run: ./deploy/apply.sh "${{ needs.build.outputs.digest }}"Cost or Time Model
Deploy jobs are short, and the minutes inside them go somewhere unintuitive: the steps that move gigabytes, rather than the step that applies the change.
Assumptions
| Input | Value | Source |
|---|---|---|
Merges to main per month | 120 | Your workflow run history |
| Deploy targets per merge | 3 (staging, canary, production) | The pipeline above, extended |
| Container image pulled per deploy | 1.6 GB | Your registry metrics |
| Artifact bundle downloaded per deploy | 0.7 GB | Your artifact retention view |
| Approval wait on production | 35 minutes | Your deployments view |
| Runner rates | $0.004 to $0.016 per minute | pricing page, checked 2026-08-13 |
Where a single deploy spends its minutes
The transfer steps are network bound, so they flatten out as the label grows, while the apply and smoke steps track cores. Substitute your own step timings from a run log before committing to a size.
| Step | 2x (2 vCPU) | 4x (4 vCPU) | 8x (8 vCPU) |
|---|---|---|---|
| Waiting for the environment gate | 35.0 min, not dispatched | 35.0 min, not dispatched | 35.0 min, not dispatched |
| Sparse checkout and OIDC exchange | 0.4 min | 0.4 min | 0.4 min |
| Pull image by digest, 1.6 GB | 2.0 min | 1.4 min | 1.3 min |
| Download artifact bundle, 0.7 GB | 0.9 min | 0.6 min | 0.6 min |
| Apply | 4.6 min | 3.4 min | 2.9 min |
| Smoke test | 1.8 min | 1.2 min | 1.0 min |
| Billed minutes | 9.7 | 7.0 | 6.2 |
| Rate per minute | $0.004 | $0.008 | $0.016 |
| Cost per deploy | $0.0388 | $0.0560 | $0.0992 |
The approval row is 35 minutes of wall clock and zero billed minutes, because the hold happens before the job reaches a runner.
Monthly, at 360 deploys
| Label | Billed minutes per month | Monthly cost | Wall clock per deploy |
|---|---|---|---|
| warp-ubuntu-latest-x64-2x | 3,492 | $13.97 | 9.7 min |
| warp-ubuntu-latest-x64-4x | 2,520 | $20.16 | 7.0 min |
| warp-ubuntu-latest-x64-8x | 2,232 | $35.71 | 6.2 min |
Moving the label from 2 vCPU to 8 vCPU buys 3.5 minutes of wall clock per deploy and costs $21.74 a month at this volume. Deploy jobs are usually where that trade is worth taking, because the minutes are few and the wait is in front of a release.
The bytes are the other half. At 2.3 GB per deploy and 360 deploys, this pipeline moves 828 GB a month out of the registry and the artifact store, and your cloud provider prices that path rather than GitHub. On the enterprise tier, runners pull large images and artifacts from ECR, S3, and similar stores with zero egress cost to you, whether your runners live in your cloud or ours; the scope is on zero egress for the enterprise tier.
Every rate above carries a source link and a checked-on date, and the step timings are assumptions to replace with your own.
FAQ
What belongs in a deployment job and what belongs upstream?
The deploy job resolves credentials, fetches an already built artifact by digest, applies it to one target, and verifies the result. Compiling, packaging, image builds, and registry pushes belong to jobs upstream of the approval gate, so the thing that ships is the thing that was tested.
Does a waiting approval cost runner minutes?
No. A job bound to an environment stays in a waiting state until every protection rule passes, and GitHub dispatches it to a runner only after that, so an approval that sits overnight and a wait timer of up to 43,200 minutes both accrue zero job minutes (deployments and environments reference, checked on 2026-08-13).
What size runner does a deployment job need?
Size it for the bytes it moves rather than for cores. A job that pulls a 1.6 GB image and a 700 MB bundle spends most of its minutes on transfer, which is network bound, so warp-ubuntu-latest-x64-4x at $0.008 per minute is the common landing spot, and warp-ubuntu-latest-x64-2x at $0.004 is cheaper per deploy while running about 2.7 minutes longer. Full rates are on the pricing page.
Start with $10 in free credits
Change the runner label in your workflow and keep the rest of your GitHub Actions setup. Runner time is billed per minute.