How Do I Limit a Workflow to One Run at a Time?

Declare a concurrency group with a fixed name and leave cancel-in-progress off, so runs queue instead of overlapping. Each group holds one pending run.

Answer

Declare a concurrency block at the workflow level with a fixed group name and leave cancel-in-progress off, which is its default. Runs in that group then serialize: the run already in progress finishes, and a run that arrives while it is going parks in a pending state instead of starting alongside it, per GitHub's workflow syntax reference.

One mechanical detail decides whether that gives you what you wanted. A group holds one running item and one pending item. When a third run enters the group, GitHub cancels the pending run and parks the newcomer in its place, so what you get is a lane with the newest commit at the front rather than a FIFO queue that replays every merge in order. For deploys that is usually the right behavior, because the newest commit is the one you want live.

Here are the two shapes side by side. The serialized shape:

name: deploy-production

on:
  push:
    branches: [main]

concurrency:
  group: deploy-production
  cancel-in-progress: false

jobs:
  deploy:
    runs-on: warp-ubuntu-latest-x64-4x
    steps:
      - uses: actions/checkout@v4
      - run: ./scripts/migrate.sh
      - run: ./scripts/deploy.sh

The cancel-newer shape, which belongs on pull request checks rather than on deploys:

name: pr-checks

on:
  pull_request:

concurrency:
  group: pr-checks-${{ github.ref }}
  cancel-in-progress: true

jobs:
  test:
    runs-on: warp-ubuntu-latest-x64-4x
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm test

Three fields separate them.

FieldSerialized deployCancel-newer checks
groupFixed string, no refScoped with github.ref
cancel-in-progressfalsetrue
Effect on the running itemFinishesCanceled by the new arrival
Effect on the pending itemReplaced by a newer arrivalNot applicable
FitsDeploys, shared external stateRepeated pushes to one pull request

Detail

The two shapes that need serialization

Only two categories of work earn a serialized lane, and both fail the same test: two copies running in the same minute leave the world in a state that neither copy intended.

Deploys to one environment. Two deploy runs racing against one cluster produce an ordering you did not choose. The older commit can land after the newer one because its image push finished first, and the environment then serves code nobody expects. The deployment jobs guide covers the full deploy shape, including how the lane sits next to environment protection rules.

Jobs that share external state. Terraform applies against one state file, schema migrations against one database, a publish step that pushes one tag to a registry, a job that holds a single seat on a device farm or a license server. Terraform makes the failure visible with a state lock error. Migrations and tag pushes often do not, which is what makes them worth serializing before an incident teaches you.

Everything else should stay parallel. Test shards, lint jobs, and matrix builds write to nothing shared, and putting them in a lane converts a five minute check into a twenty minute one for no gain.

Name the group after the thing being protected

The group name is the whole design. Name it after the resource, and the lane covers exactly the runs that touch that resource.

  • deploy-production serializes every deploy to production, whichever workflow or branch triggered it.
  • deploy-${{ github.event.inputs.environment }} gives one lane per environment, so staging and production never block each other.
  • pr-checks-${{ github.ref }} gives one lane per branch, which is the pattern you want with cancel-in-progress: true.

A group name is shared across the whole repository, so two workflows carrying the same string serialize against each other whether or not that was the intent. The glossary entry on concurrency groups covers the term, and what happens when two workflows share a concurrency group walks through the collision case and the naming convention that avoids it.

When a workflow contains both a wide test matrix and a single writer, put the block inside the job instead of at the top of the file:

jobs:
  test:
    strategy:
      matrix:
        shard: [1, 2, 3, 4, 5, 6]
    runs-on: warp-ubuntu-latest-x64-4x
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm test -- --shard=${{ matrix.shard }}/6

  deploy:
    needs: test
    concurrency:
      group: deploy-production
      cancel-in-progress: false
    runs-on: warp-ubuntu-latest-x64-4x
    steps:
      - uses: actions/checkout@v4
      - run: ./scripts/deploy.sh

Six shards still start together. Only deploy takes the lane.

What the queue costs

Take a deploy job that runs 9 minutes on warp-ubuntu-latest-x64-4x, which is 4 vCPU and 16 GB at $0.008 per minute from the cloud runners catalog, checked on 2026-08-13. That is $0.072 per deploy. Now land three merges on main inside one busy morning.

TimeEventGroup stateResult
09:00Merge AA runningA deploys, 9 billed minutes
09:03Merge BA running, B pendingB waits, no runner assigned
09:05Merge CA running, C pendingB canceled, C takes the pending slot
09:09A finishesC runningC deploys, 9 billed minutes

The wall clock cost is real: C shipped at 09:18 instead of 09:14. The money moves the other way. Billing starts when a job starts on a runner (GitHub Actions billing, checked on 2026-08-13), so B billed nothing while pending and nothing when canceled. Serialized: 18 billed minutes, $0.144. Unserialized: three deploys, 27 billed minutes, $0.216, and two of the three racing against one cluster.

Set that against what an overlapping pair costs to unwind. A Terraform state lock error blocks the next apply until somebody clears it by hand. A half-applied migration takes a restore window. A container tag pointing at the older image ships a rollback nobody asked for. Four minutes of lane time per collision is the cheaper side of that trade, and the lane is the only part of it you control from YAML.

Price the lane against the size you actually need before widening anything:

runs-on labelOSvCPURAMRate per minute
warp-ubuntu-latest-x64-2xUbuntu 24.0428GB$0.004
warp-ubuntu-latest-x64-4xUbuntu 24.04416GB$0.008
warp-ubuntu-latest-arm64-4xUbuntu 24.04416GB$0.006
warp-macos-latest-arm64-6xmacOS622GB$0.08

A serialized deploy is the job where a shorter runtime pays twice, because the duration sets both the wall clock and the width of the window in which the next merge has to wait. warp-ubuntu-latest-x64-4x costs $0.008 per minute against $0.012 per minute for the 4-core Linux larger runner of the same 4 vCPU, 16 GB shape: 33 percent lower list price (GitHub Actions per-minute rates, checked on 2026-08-13). Full per-minute rates by runner type sit on the pricing page.

Make sure the wait you see is the lane you wrote

A run held by a concurrency group and a job waiting for a free runner look identical from the pull request page, and they have opposite fixes. Measure before editing YAML. The Queue Timings report breaks queue wait into P75 and P90 per runner label and stack, with CSV export, per the reports documentation. That number covers waiting for a runner, so a flat P90 next to a run showing a Waiting banner names your own group as the cause.

Runner capacity should not be the other half of the answer. Run as many jobs as your workflows need. Generally available Linux and Windows runners do not have plan-level concurrency caps, and capacity adjusts as workflows fan out, which is scoped in the cloud runners documentation to Generally Available features on Linux and Windows runners. macOS concurrency runs on a per-organization quota arranged with support at [email protected].

Pricing is purely usage based. The guide to speeding up GitHub Actions covers the duration side of the same problem, which is the lever that shrinks the lane.

How do I make GitHub Actions runs queue instead of cancel each other?

Leave cancel-in-progress out of the concurrency block, or set it to false, which is the default in GitHub's workflow syntax reference. The run already in progress finishes and the next arrival waits. Each group holds one pending run, so a third arrival cancels the pending run and takes the pending slot, which means intermediate commits get skipped and the newest one deploys.

Does a run waiting on a concurrency group cost runner minutes?

No. A run parked by a concurrency group has no runner assigned to it, and billing starts when a job starts on a runner (GitHub Actions billing, checked on 2026-08-13). A pending run canceled by a newer arrival costs nothing at all, which is why the serialized timeline above bills 18 minutes where the unserialized one bills 27.

Should the concurrency group name include the branch ref?

For serializing deploys to one environment, no. A fixed name such as deploy-production keeps every deploy in one lane whichever branch triggered it. Include github.ref when you want one lane per branch, which is the pull request check pattern. Because the name is shared across the repository, check it against every other workflow first: what happens when two workflows share a concurrency group covers the collision.

Can I serialize one job instead of the whole workflow?

Yes. A concurrency block inside a job governs that job across runs, so a six-shard test matrix keeps fanning out while only the deploy job takes the lane. That is the shape the deployment jobs guide builds on, and the concurrency group glossary entry defines the term the two blocks share.

Confirm your own wait is the lane rather than the pool with the Queue Timings numbers in the reports documentation, then price the deploy job against the per-minute rates on the pricing page. The guide to speeding up GitHub Actions covers the rest of the levers once queueing is not the bottleneck.

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.