Kubernetes Deploy Jobs on GitHub Actions

Kubernetes deployments on GitHub Actions split into a build job and a rollout job. Here are the warp- labels, sizes, per-minute rates and transfer costs.

Last verified:

A Kubernetes deployment on GitHub Actions is two jobs with different shapes: a build job that produces an image and a deploy job that authenticates to the cluster and rolls the image out. Sizing them as one job is the most common way teams overpay, because the build job is compute bound for a few minutes and the deploy job is idle for most of its life while kubectl rollout status waits on the cluster.

This page gives the workflow for both jobs on warp- labels, the catalog rows and per-minute rates for each, the private cluster access path through the networking addon, the gigabytes a rollout pulls and what they cost at published cloud rates, and the failure modes that show up once the pipeline runs every merge.

Overview

Split the pipeline at the point where the work changes character.

The build job compiles, packages, and pushes. It wants cores, a warm layer cache, and a fast path to the registry. Remote Docker builders run outside the GitHub Actions runner with a persistent layer cache on local disks, so repeat builds reuse layers instead of rebuilding them. The build side is covered in depth on Docker builds on GitHub Actions with remote builders.

The deploy job does something else entirely. It assumes a cloud role, writes a kubeconfig, applies a manifest or a chart, and then blocks until the rollout reports healthy. Its CPU sits near zero for most of its runtime. What it needs is network reach to the cluster API server and credentials that expire.

Three costs sit in that second job, and none of them are compute.

The first is wait time. A runner billed per minute is billed while helm --wait and kubectl rollout status poll the API server. A ten minute readiness gate is ten billed minutes on whatever shape you picked.

The second is reach. Clusters with a private API endpoint reject a runner that has no route into the network, and the usual workarounds are a bastion, a self-hosted fleet inside the cluster network, or an allowlisted static IP address that then carries its own data processing charges.

The third is bytes. Deploy jobs pull images and release artifacts out of stores you own, and the meter counts gigabytes rather than jobs. That line lands on your cloud bill instead of the GitHub Actions invoice, which is why it survives so many cost reviews. What ECR pulls cost in a GitHub Actions pipeline works that number in detail.

Configuration

The two-job workflow

The build job runs on a 4 vCPU runner and hands the image build to a builder profile. The deploy job runs on a 2 vCPU runner carrying the networking addon label, and consumes the image reference through a job output so it never resolves a floating tag.

name: deploy-api
on:
  push:
    branches: [main]

permissions:
  id-token: write
  contents: read

jobs:
  build:
    runs-on: warp-ubuntu-latest-x64-4x
    outputs:
      image: ${{ steps.ecr.outputs.registry }}/api@${{ 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/github-actions-deploy
          aws-region: us-east-1

      - uses: aws-actions/amazon-ecr-login@v2
        id: ecr

      - name: Build and push
        id: push
        uses: Warpbuilds/build-push-action@v6
        with:
          context: .
          push: true
          tags: ${{ steps.ecr.outputs.registry }}/api:${{ github.sha }}
          profile-name: "api-images-x64"
          timeout: 600000

  deploy:
    needs: build
    runs-on: warp-ubuntu-latest-x64-2x;network.name=production-tailnet
    steps:
      - uses: actions/checkout@v4

      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::111122223333:role/github-actions-deploy
          aws-region: us-east-1

      - uses: azure/setup-helm@v4
        with:
          version: v3.16.2

      - name: Write kubeconfig
        run: aws eks update-kubeconfig --name prod-use1 --region us-east-1

      - name: Roll out
        run: |
          helm upgrade api ./charts/api \
            --install \
            -n api \
            --set image.reference=${{ needs.build.outputs.image }} \
            --wait --timeout 10m

      - name: Confirm rollout
        run: kubectl rollout status deployment/api -n api --timeout=5m

Two details in that file are worth calling out. The build job publishes a digest rather than a tag, so the rollout pins the exact bytes that were built and a retag upstream cannot change what the cluster runs. And the deploy job carries no builder profile, because it builds nothing.

Private cluster access

The network.name=<config-name> modifier on the runs-on label joins the runner to your Tailscale tailnet at job start. WarpBuild authenticates the runner with Tailscale OIDC and ephemeral node keys, the runner reaches any device on the tailnet, and the ephemeral node is removed when the job finishes. The configuration name in the label is the Network Addon Configuration you created in the dashboard. Full setup is in the networking documentation.

Modifiers stack on one label, so a deploy job can join the tailnet and restore a snapshot at the same time:

jobs:
  deploy:
    runs-on: warp-ubuntu-latest-x64-2x;network.name=production-tailnet

  migrate:
    runs-on: warp-ubuntu-latest-x64-2x;network.name=production-tailnet;snapshot.key=migrations

Extra tailscale up flags go in the Additional Arguments field on the addon configuration, not in the workflow file. These are the ones that matter for a deploy job:

FlagEffect on a deploy job
--advertise-tags=tag:ciApplies the ACL tag that grants the runner reach to the API server
--accept-routesAccepts subnet routes advertised by a node in front of the cluster network
--accept-dns=falseLeaves resolver behavior to the runner when your kubeconfig uses cloud DNS
--advertise-routes=10.0.0.0/24Advertises routes from the runner itself

Five flags are reserved and set by WarpBuild: --hostname, --auth-key, --client-id, --id-token, and --state. Passing any of them in Additional Arguments is rejected, so identity and state handling stay with the platform. Tags passed through --advertise-tags must be a subset of the tags configured on the Tailscale trust credential, and the ACL policy decides what the tagged runner can reach. Tailscale is pre-installed on all WarpBuild runner images, and the setup script runs only when the addon is requested. BYOC runners on custom images need jq present.

Both jobs also run outside GitHub Actions if you need them to. BYOC runs on AWS, GCP, and Azure, and Terraform support exists for BYOC on AWS, so the runner fleet can live in the same module as the cluster it deploys to. Infrastructure jobs that plan and apply that module are covered on Terraform plan and apply jobs on GitHub Actions.

Sizing

Size the two jobs against different constraints, and price them separately.

The deploy job

The deploy job needs enough memory for helm template on a large chart and nothing else. Start at 2 vCPU and move up only if manifest rendering or a migration container is genuinely slow.

Runner labelOSvCPURAMStoragePrice per minute
warp-ubuntu-latest-x64-2xUbuntu 24.0428 GB150GB SSD$0.004
warp-ubuntu-latest-x64-4xUbuntu 24.04416 GB150GB SSD$0.008
warp-ubuntu-latest-arm64-2xUbuntu 24.0428 GB150GB SSD$0.003
warp-ubuntu-latest-arm64-4xUbuntu 24.04416 GB150GB SSD$0.006

Rates from the pricing page, checked on 2026-08-13. ARM64 labels are the cheaper row for a job whose tooling ships ARM64 binaries, which kubectl, helm, and the AWS CLI all do.

The build job

The build job is two billed resources: the runner that ships the build context and the builder profile that runs the build. Size the profile against the widest fan-out that hits it at once, at the documented minimum of roughly 8 vCPU and 16GB memory per concurrent build job.

ResourceShapePrice per minute
warp-ubuntu-latest-x64-4x runner4 vCPU, 16 GB, 150GB SSD$0.008
warp-ubuntu-latest-x64-8x runner8 vCPU, 32 GB, 150GB SSD$0.016
Builder profile16 vCPU, 32GB, 100GB disk$0.06
Builder profile32 vCPU, 64GB, 200GB disk$0.12
Builder profile64 vCPU, 128GB, 200GB disk$0.24

Rates from the pricing page, checked on 2026-08-13. Builder sessions bill per minute from the first job's builder start to the last job's completion, and a multi-architecture profile opens one session per architecture.

Worked model for a deploy pipeline

Assumptions: eight services, each deploying three times per weekday, 22 weekdays a month, so 528 rollouts. Each build job holds the runner 6 minutes and the builder 4 minutes on one 16 vCPU profile. Each deploy job holds a 2 vCPU runner 8 minutes, of which about 5 are readiness waiting.

LineArithmeticMonthly
Build runner minutes528 x 6 min x $0.008$25.34
Builder session minutes528 x 4 min x $0.06$126.72
Deploy runner minutes528 x 8 min x $0.004$16.90
Total$168.96

The runner lines are the ones with a GitHub-hosted equivalent. Those 3,168 build minutes list at $0.012 per minute on the 4-core Linux larger runner and those 4,224 deploy minutes list at $0.006 per minute on ubuntu-latest for private repositories, which totals $63.36 against $42.24 of WarpBuild runner minutes (GitHub Actions billing reference, checked on 2026-08-13). The builder line has no GitHub-hosted counterpart, so compare it against the build minutes it removes from the runner rather than against a list price.

What the rollout pulls

Runner minutes are the smaller half of this pipeline once the registry is involved. Price the bytes with the same discipline: a per-rollout figure, a published rate, and a date.

InputValueSource
Rollouts per month528Your workflow run history
Image bytes pulled by the deploy job1.2 GBYour registry metrics
Chart, manifest, and release artifacts from S30.4 GBYour bucket metrics
Internet data transfer rate$0.09 per GBAmazon ECR pricing
ECR to same-Region compute$0.00 per GBAmazon ECR pricing
Free internet transfer allowance100 GB per month, all servicesAmazon EC2 On-Demand pricing

All AWS rates checked on 2026-08-13. Per rollout the deploy job moves 1.2 plus 0.4, which is 1.6 GB. Across 528 rollouts that is 844.8 GB a month.

Runner placementArithmeticMonthly
Outside your account, pulling over the internet path744.8 billable GB x $0.09$67.03
Inside your account and Region844.8 GB x $0.00$0.00

The first row credits the 100 GB monthly allowance once, and that allowance is shared with production, so a busy account has usually spent it already. The cluster-side pulls are a separate volume: six replicas pulling a 1.2 GB image is 7.2 GB per rollout, priced at $0.00 per GB by AWS when the nodes and the registry share a Region.

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 and a worked model are on zero egress for the enterprise tier.

Bottlenecks

Readiness waiting is billed runner time

In the model above, roughly 5 of the 8 deploy minutes are the runner polling the API server. That is 528 x 5 x $0.004, or $10.56 a month, and it is the line that grows when someone raises a --timeout after a flaky release.

Two things shrink it. Set the readiness gate to the value your rollout actually needs rather than a round number nobody revisits. And keep the waiting job on the smallest shape, because a 2 vCPU runner waits at $0.004 per minute while a 4 vCPU runner waits at $0.008 for the same result.

The builder session stays open longer than the build

Billing runs from the first job's builder start to the last job's completion on that profile. A build job that assigns a builder, then runs a test suite, then builds, keeps the session open through the tests. Put the build step next to the builder setup and let tests run in their own job on a plain runner.

The runner and the builder are separate resources on the invoice. Reading the builder line as though it were runner minutes is the usual source of surprise on the first bill.

Image size drives both halves

Every gigabyte in the image is paid twice: once on the deploy job's pull and again on every node that runs a replica. A multi-stage Dockerfile that copies only the compiled output into a slim runtime base cuts both. Ordering the Dockerfile so dependency manifests are installed before application source is what decides the cache hit rate on the builder, and no builder makes a badly ordered Dockerfile cache well.

Cold profiles on slow-moving services

A builder profile that goes unused for more than 10 days is reset automatically. Services that deploy weekly can land on a cold cache and see build times that look like a regression. Point several low-frequency services at one shared profile so it stays warm, and remember that jobs sharing a profile share its CPU, memory, and disk while they run.

Credentials and blast radius

A deploy job holds cloud credentials and cluster credentials at the same time. Use short-lived OIDC credentials rather than static keys, scope the assumed role to the workloads the job touches, and keep the tailnet ACL tag narrow enough that a compromised job reaches the API server and nothing else. The ephemeral node leaves the tailnet when the job ends, so a stale node never accumulates access.

What none of this fixes

A rollout that fails on a bad readiness probe, a migration that locks a table, or a chart that renders an invalid manifest fails the same way on any runner. Faster machines shorten the feedback loop on those failures without preventing them.

Proof

The configuration on this page is verifiable in public. Public OSS repositories running warp- labels are citable evidence, and the clearest one for the build half is the warp-e2e workflow in the Warpbuilds/build-push-action repository. It runs on runs-on: warp-ubuntu-latest-x64-4x, builds against a named builder profile, then asserts that the active buildx driver is remote and that the node endpoint is a tcp:// address, which proves the build left the runner. It rebuilds and compares digests, which proves the profile served layers from cache on the second pass. The WarpBuild runner agent is open source as well.

Every cost figure on this page carries its arithmetic, a source link, and a checked-on date, and the same rates appear on the pricing page. Cloud list prices move, so re-check the AWS and GitHub links before quoting these totals internally, and substitute your own image size and rollout count.

FAQ

Can a GitHub Actions runner reach a private Kubernetes cluster?

Yes. Add network.name=<config-name> to the runs-on label and the runner joins your Tailscale tailnet as an ephemeral node at job start, then leaves it when the job finishes. Tailscale is pre-installed on all WarpBuild runner images, and the networking addon is listed at $0.00 per minute on the pricing page.

Should the build job and the deploy job run on the same runner size?

No. The build job needs cores and a remote Docker builder profile. The deploy job runs kubectl or helm and then waits on the cluster, so warp-ubuntu-latest-x64-2x at $0.004 per minute covers it. Sizing the deploy job like the build job pays 4 vCPU rates for a process that is idle most of its life.

What does one Kubernetes rollout cost in cloud data transfer?

Count the gigabytes the deploy job pulls, not the jobs. A rollout that pulls a 1.2 GB image and 0.4 GB of chart and release artifacts moves 1.6 GB. At 528 rollouts a month that is 844.8 GB, which AWS prices at $0.00 per GB when the runner sits in the same Region as the registry and at internet transfer rates when it does not. Checked on 2026-08-13.

Do I need a Docker builder profile for a deploy-only workflow?

No. A workflow that only rolls out an image someone else built needs a runner and cluster credentials. Builder profiles are billed per session and only bill when a job assigns one, so a deploy-only repository never opens a session.

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.