Serverless Deployments from GitHub Actions
One package job builds the deployment artifact and every environment ships those exact bytes. The warp- labels, the OIDC workflow, and the transfer math.
Overview
A serverless deployment on GitHub Actions is a package job that builds one deployment artifact and one deploy job per environment that fetches those exact bytes and points a function at them. The split carries more weight here than in most pipelines, because the deploy step is an upload followed by an alias change, so any job that rebuilds before uploading ships bytes that nothing tested.
This page covers the package and deploy split, a workflow with an OIDC credential exchange and per-environment deploy jobs, the warp- labels and rates for each side, what a large deployment package costs when the deploy jobs pull it, and the failure modes that show up once the pipeline runs on every merge.
The package job does the work that consumes cores. It installs dependencies, bundles handlers, prunes development dependencies, produces a zip, and records the checksum of that zip as a job output. The artifact goes to artifact storage once.
Each deploy job does something narrower. It downloads the artifact, verifies the checksum against the packaging job's output, exchanges the workflow OIDC token for short-lived cloud credentials, stages the zip in a bucket in the target account, updates the function code, waits for the update to settle, publishes a version, and shifts the alias. No compile step sits behind the approval gate.
The checksum verification is what makes promotion real. A package rebuilt for production resolves dependencies again, and a transitive dependency published in the twenty minutes between the staging deploy and the production approval changes the bytes without changing the commit. Comparing a sha256 recorded upstream against the file on the deploy runner turns that into a failed step rather than a production incident. The general shape of that handoff is covered in build once and deploy many with GitHub Actions.
Serverless packaging and deploy jobs live on the Linux labels, and per-size rates are on the pricing page. These jobs hold cloud credentials, so runner isolation matters: each runner is a fresh virtual machine with its own encrypted storage volume, created on demand and destroyed after the job, as described in the security documentation.
Configuration
One package job, two deploy jobs, one artifact between them. The functions in this example run on Lambda's arm64 architecture, so the package job runs on an ARM64 label and the native modules in the bundle match the target.
name: deploy-functions
on:
push:
branches: [main]
permissions:
contents: read
jobs:
package:
runs-on: warp-ubuntu-latest-arm64-4x
outputs:
checksum: ${{ steps.hash.outputs.sha256 }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- name: Bundle handlers
run: npx esbuild src/handlers/*.ts --bundle --platform=node --target=node22 --outdir=dist
- name: Install runtime dependencies only
run: npm ci --omit=dev --prefix dist
- name: Zip the deployment package
run: cd dist && zip -qr ../function.zip .
- name: Record the package checksum
id: hash
run: echo "sha256=$(sha256sum function.zip | cut -d' ' -f1)" >> "$GITHUB_OUTPUT"
- uses: actions/upload-artifact@v4
with:
name: function-package
path: function.zip
retention-days: 14
deploy-staging:
needs: package
runs-on: warp-ubuntu-latest-arm64-2x
environment: staging
permissions:
contents: read
id-token: write
concurrency:
group: deploy-functions-staging
cancel-in-progress: false
steps:
- uses: actions/download-artifact@v4
with:
name: function-package
- name: Verify the package is the one that was built
run: echo "${{ needs.package.outputs.checksum }} function.zip" | sha256sum -c -
- name: Exchange the GitHub OIDC token for AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::111122223333:role/deploy-functions-staging
aws-region: us-east-1
- name: Stage the package in S3
run: aws s3 cp function.zip s3://acme-releases-staging/${{ github.sha }}.zip
- name: Update, publish, and shift the alias
run: |
aws lambda update-function-code \
--function-name checkout-api \
--s3-bucket acme-releases-staging \
--s3-key ${{ github.sha }}.zip \
--architectures arm64
aws lambda wait function-updated --function-name checkout-api
VERSION=$(aws lambda publish-version \
--function-name checkout-api \
--query Version --output text)
aws lambda update-alias \
--function-name checkout-api \
--name staging \
--function-version "$VERSION"
deploy-production:
needs: [package, deploy-staging]
runs-on: warp-ubuntu-latest-arm64-2x
environment: production
permissions:
contents: read
id-token: write
concurrency:
group: deploy-functions-production
cancel-in-progress: false
steps:
- uses: actions/download-artifact@v4
with:
name: function-package
- name: Verify the package is the one that was built
run: echo "${{ needs.package.outputs.checksum }} function.zip" | sha256sum -c -
- name: Exchange the GitHub OIDC token for AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::444455556666:role/deploy-functions-production
aws-region: us-east-1
- name: Stage the package in S3
run: aws s3 cp function.zip s3://acme-releases-production/${{ github.sha }}.zip
- name: Update, publish, and shift the alias
run: |
aws lambda update-function-code \
--function-name checkout-api \
--s3-bucket acme-releases-production \
--s3-key ${{ github.sha }}.zip \
--architectures arm64
aws lambda wait function-updated --function-name checkout-api
VERSION=$(aws lambda publish-version \
--function-name checkout-api \
--query Version --output text)
aws lambda update-alias \
--function-name checkout-api \
--name production \
--function-version "$VERSION"Four details in that file earn their place.
The two deploy jobs assume different roles in different accounts. Each exchange runs against the GitHub OIDC provider with id-token: write on the job, and the trust policy on each role restricts it to the repository and to the environment named on the job. No long-lived access key sits in repository secrets.
The package goes through S3 rather than into the API call. AWS caps a package uploaded directly at 50 MB zipped, which a bundled Node or Python service passes quickly, so --s3-bucket and --s3-key are the shape worth writing from the start.
The wait sits between the update and the publish. update-function-code returns while the update is still in progress, and calling publish-version during that window fails with a conflict. aws lambda wait function-updated is the step that makes the release deterministic.
cancel-in-progress is false on both deploy jobs. Cancelling a deploy between update-function-code and update-alias leaves new code published and the alias pointing at the old version, which takes a person to reconcile.
Packaging format decides the ceiling, and AWS publishes each limit:
| Package shape | AWS quota | How the deploy job ships it |
|---|---|---|
| Zip in the API call | 50 MB zipped | --zip-file fileb://function.zip |
| Zip staged in S3 | 250 MB unzipped, function and layers together | --s3-bucket and --s3-key |
| Container image | 10 GB | --image-uri pointing at an ECR digest |
Quotas from the AWS Lambda quotas reference, checked on 2026-08-13. A service that outgrows the zip path moves to the image path, and the deploy job then pushes to ECR instead of a bucket.
Sizing
Size the two job types separately. Shapes come from the cloud runners documentation and rates from the pricing page.
| Runner label | vCPU | Memory | Storage | Price per minute |
|---|---|---|---|---|
| warp-ubuntu-latest-x64-2x | 2 | 8GB | 150GB SSD | $0.004 |
| warp-ubuntu-latest-x64-4x | 4 | 16GB | 150GB SSD | $0.008 |
| warp-ubuntu-latest-x64-8x | 8 | 32GB | 150GB SSD | $0.016 |
| warp-ubuntu-latest-arm64-2x | 2 | 8GB | 150GB SSD | $0.003 |
| warp-ubuntu-latest-arm64-4x | 4 | 16GB | 150GB SSD | $0.006 |
The deploy job runs the AWS CLI, uploads a file, and polls until the function reports updated. Two cores cover that at $0.004 per minute on x64 and $0.003 on ARM64.
The package job installs dependencies and bundles, so 4 vCPU at $0.008 per minute is the usual landing spot. Reach for the 8x label when the repository packages a dozen functions in one pass or runs a TypeScript compile before bundling.
Architecture is a correctness question before it is a price question. Functions configured for Lambda's arm64 architecture want packages built on an ARM64 runner, because any dependency with a compiled binary ships the wrong object file when it is installed on x64. The ARM64 labels are also the cheaper row at every size.
Worked cost model
Twelve service repositories, each releasing 40 times a month, so 480 releases. Each package job holds a 4 vCPU runner 5 minutes. Each release runs two deploy jobs of 4 minutes on a 2 vCPU runner.
| Line | Arithmetic | Monthly |
|---|---|---|
| Package minutes | 480 x 5 min x $0.008 | $19.20 |
| Deploy minutes | 480 x 2 x 4 min x $0.004 | $15.36 |
| Total | $34.56 |
GitHub publishes per-minute list prices in the GitHub Actions billing reference. Checked on 2026-08-13, the 4-core Linux larger runner is $0.012 per minute and standard ubuntu-latest for private repositories is $0.006 per minute. The same 2,400 package minutes and 3,840 deploy minutes come to $28.80 plus $23.04, or $51.84. The arithmetic at each size: $0.012 minus $0.008 is $0.004, and $0.004 divided by $0.012 is 33 percent lower list price at 4 vCPU, with the same 33 percent at 2 vCPU from $0.006 against $0.004 (GitHub pricing, checked on 2026-08-13).
Rebuilding the package inside each deploy job adds two more packaging runs per release: 480 x 10 min x $0.008, or $38.40 a month, which is more than the entire pipeline above.
What the deploy jobs pull
Runner minutes are the visible half. Bytes are the half that lands on the cloud bill, and container-image functions move the most of them, because a job that scans or verifies an image before publishing pulls the whole thing.
| Input | Value | Source |
|---|---|---|
| Releases per month | 480 | Your workflow run history |
| Environments per release | 2 | This pipeline |
| Image pulled per deploy job | 1.1 GB | Your ECR metrics |
| Internet data transfer rate | $0.09 per GB | Amazon ECR pricing |
| ECR to same-Region compute | $0.00 per GB | Amazon ECR pricing |
| Free internet transfer allowance | 100 GB per month, all services | Amazon EC2 On-Demand pricing |
AWS rates checked on 2026-08-13. Per month that is 480 x 2 x 1.1, or 1,056 GB.
| Runner placement | Arithmetic | Monthly |
|---|---|---|
| Outside your account, pulling over the internet path | 956 billable GB x $0.09 | $86.04 |
| Inside your account and Region | 1,056 GB x $0.00 | $0.00 |
The first row credits the 100 GB monthly allowance once, and that allowance is shared with production traffic, so a busy account has usually spent it before the pipeline runs. 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 on the enterprise tier.
Bottlenecks
Cold dependency installs. The package job spends most of its wall time in npm ci or pip install. Cache the package manager directory keyed on the lockfile, and keep the cache key off anything that changes every commit.
Packages that outgrow their quota at deploy time. A bundle that creeps past 250 MB unzipped fails in the deploy job, after the approval, with a quota error rather than a build error. Prune development dependencies during packaging, move shared libraries into layers, and treat the container image path as the destination for a service that keeps growing.
Architecture mismatch in native modules. An x64 build of a compiled dependency deployed to an arm64 function passes packaging and fails at the first cold start with an import error. Building on the label that matches the function architecture removes the class of failure.
Conflicts between overlapping releases. Two runs updating one function collide, and a run that starts while an update is in progress ends with a conflict error. A fixed concurrency group per environment plus aws lambda wait function-updated is what serializes them.
Alias drift after a partial deploy. A cancelled or failed job between publish and alias update leaves a published version nobody routes to. Roll back by shifting the alias to the previous version number rather than by redeploying, and record the previous version in the job before the shift.
Opaque failures. When a deploy job hangs and the cause is unclear, CI observability correlates system metrics from the runner agent with GitHub Actions job logs, which separates a job waiting on a cloud API from one that is out of disk. The Action Debugger pauses a workflow and opens an SSH session on the runner, so the package on disk and the credentials in the environment can be inspected on the machine itself. The gating side of this pipeline is covered in how to shape a deployment job on GitHub Actions.
Proof
Public OSS repositories running warp- labels are citable evidence, so the runs-on line can be read directly instead of taken on trust. The Trigger.dev end-to-end suite runs its matrix on warp-ubuntu-latest-x64-4x and warp-windows-latest-x64-8x in triggerdotdev/trigger.dev's e2e.yml, checked on 2026-08-13. That is the same 4x Linux label the package job above would use on x64.
The cheaper check is your own pipeline. Signup includes $10 free credits, which covers 2,500 minutes on warp-ubuntu-latest-x64-2x or about 3,300 minutes on warp-ubuntu-latest-arm64-2x. Point the package job at a warp- label, leave the deploy jobs where they are, and compare packaging time for the same commit.
Every rate on this page carries its arithmetic, a source link, and the date it was checked, and the same numbers 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 release count and package size.
FAQ
How do I deploy a Lambda package larger than 50 MB?
Stage it in S3 first. AWS caps a deployment package uploaded directly in the API call at 50 MB zipped, so a larger package goes to a bucket with aws s3 cp and then to the function with update-function-code --s3-bucket and --s3-key. The function plus its layers still has to fit the 250 MB unzipped quota, and past that the packaging format changes to a container image, which AWS allows up to 10 GB.
Should each environment build its own deployment package?
No. One package job publishes the artifact, records its sha256, and every deploy job downloads that artifact and verifies the checksum before it touches a function. Rebuilding per environment pays the packaging minutes once per target and lets a dependency published between two deploys change the bytes production runs.
What runner size does a serverless deploy job need?
The deploy job uploads a package and changes an alias, so warp-ubuntu-latest-x64-2x at $0.004 per minute or warp-ubuntu-latest-arm64-2x at $0.003 per minute covers it. The packaging job installs dependencies and bundles, which is the part that uses cores, so it belongs on a 4x label at $0.008 and $0.006 per minute.
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.