Deploy to Production with GitHub Actions
A practical guide to production deploys with GitHub Actions — environments, secrets, OIDC, deploy-on-tag vs deploy-on-main, and pushing images to AWS, Vercel, or Fly.
What you'll learn
- ✓How GitHub Environments unlock manual approvals and per-env secrets
- ✓Why OIDC is the right way to authenticate to AWS, GCP, and Azure
- ✓When to deploy on every main commit vs only on tags
- ✓How to build a Docker image once and promote it across environments
- ✓Concrete deploy jobs for AWS (ECS/ECR), Vercel, and Fly.io
Prerequisites
- •A working first workflow — see GitHub Actions: Your First Workflow
- •A target you can deploy to (an AWS account, a Vercel project, or a Fly app)
A green CI run is satisfying, but the test of a pipeline is whether it can put a change in front of users without anybody opening a console. This post is about that last mile: building once, signing into a cloud safely, gating the deploy with environments, and the specific YAML that ships to a few popular targets.
The mental model
Most production-grade deploy pipelines look like this:
- Build the application (and a Docker image) on every push.
- Test the result.
- Publish the artifact (image or bundle) to a registry, tagged with the commit SHA.
- Deploy the same artifact to staging on every merge to
main. - Promote to production either automatically, on a Git tag, or after a manual approval.
The crucial property is that the artifact deployed to production is the exact same bytes that passed tests and ran in staging. Rebuilding between environments is a recipe for surprise.
Environments and approvals
GitHub Environments are the building block. An environment is a named target (staging, production) with its own secrets, variables, and optional protection rules: required reviewers, wait timers, and branch restrictions.
Create them in Settings → Environments. For production, the usual setup is:
- Required reviewers (one or two people on the team)
- Restrict deployments to the
mainbranch (or to tags) - A short wait timer if you want a cancellation window
In your workflow, the environment key on a job opts into those rules:
deploy-prod:
runs-on: ubuntu-latest
environment:
name: production
url: https://app.example.com
steps:
- run: ./deploy.sh
When a run reaches that job, GitHub pauses for approval and surfaces the environment URL in the UI once the job completes. Secrets defined on the production environment are only available to jobs that reference it — a sharper boundary than repo-wide secrets.
OIDC: stop storing long-lived cloud keys
The old way to deploy to AWS or GCP was to mint an access key, paste it into a repo secret, and hope nothing leaks. The modern way is OIDC (OpenID Connect): GitHub signs a short-lived token describing the workflow run, the cloud verifies the token’s claims, and grants temporary credentials. No long-lived keys in the repo.
For AWS, set up a single IAM role with a trust policy that accepts tokens from token.actions.githubusercontent.com and limits them to your repo. Then in the workflow:
permissions:
id-token: write
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/github-actions-deploy
aws-region: us-east-1
- run: aws sts get-caller-identity
# output: { "UserId": "...", "Account": "123456789012", ... }
GCP and Azure have analogous setups (google-github-actions/auth and azure/login). Whenever the option exists, OIDC is the right answer.
Build once, promote everywhere
A clean pipeline builds a Docker image once and tags it with the commit SHA. Staging and production both deploy that exact tag.
name: ci-cd
on:
push:
branches: [main]
pull_request:
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
outputs:
image: ${{ steps.meta.outputs.image }}
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- id: meta
run: |
echo "image=ghcr.io/${{ github.repository }}:${{ github.sha }}" >> "$GITHUB_OUTPUT"
- uses: docker/build-push-action@v6
with:
push: true
tags: ${{ steps.meta.outputs.image }}
Downstream jobs deploy ghcr.io/<repo>:<sha> to each environment. If staging is healthy, the same image moves to production unchanged.
Deploy-on-main vs deploy-on-tag
There are two healthy patterns. Pick one and stick with it.
Deploy-on-main (continuous deployment). Every merge to main deploys to staging automatically, and either continues to production after manual approval or after passing automated smoke tests. Best for fast-moving teams that own the product end-to-end.
on:
push:
branches: [main]
Deploy-on-tag (release-based). Pushing a Git tag like v1.4.2 triggers the production deploy. Useful when you ship to many customers, need release notes per version, or have a slower compliance cadence.
on:
push:
tags:
- "v*.*.*"
You can combine them: main always goes to staging, tags always go to production. The branch and tag filters keep workflows from stepping on each other.
Manual approvals in practice
Even continuous deployment benefits from a single human checkpoint right before production. The environment setup above does this for free — when the deploy-prod job runs, GitHub blocks it until a reviewer clicks Approve in the Actions tab.
Tips that make this pleasant:
- Put the release notes (or the GitHub PR title that triggered it) into the job’s name so reviewers see context.
- Include a smoke test job that runs against staging before production is even queued. Reviewers approve a known-good build, not a roll of the dice.
- Add a rollback workflow that takes a SHA as an input and re-deploys it. You will use it.
Deploying to AWS (ECS + ECR)
A realistic AWS pipeline pushes to ECR, then updates an ECS service. With OIDC and a pre-existing task definition file in the repo:
deploy-aws:
needs: build
runs-on: ubuntu-latest
environment: production
permissions:
id-token: write
contents: read
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/github-actions-deploy
aws-region: us-east-1
- uses: aws-actions/amazon-ecr-login@v2
- name: Tag and push to ECR
run: |
docker pull ${{ needs.build.outputs.image }}
docker tag ${{ needs.build.outputs.image }} \
123456789012.dkr.ecr.us-east-1.amazonaws.com/web:${{ github.sha }}
docker push 123456789012.dkr.ecr.us-east-1.amazonaws.com/web:${{ github.sha }}
- uses: aws-actions/amazon-ecs-render-task-definition@v1
id: taskdef
with:
task-definition: deploy/taskdef.json
container-name: web
image: 123456789012.dkr.ecr.us-east-1.amazonaws.com/web:${{ github.sha }}
- uses: aws-actions/amazon-ecs-deploy-task-definition@v2
with:
task-definition: ${{ steps.taskdef.outputs.task-definition }}
service: web
cluster: prod
wait-for-service-stability: true
The pattern: render the task definition with the new image tag, register it, and wait for ECS to drain old tasks. The same shape works for EKS with kubectl set image or for Lambda with aws lambda update-function-code.
Deploying to Vercel
Vercel ships a small action that wraps the CLI. Frontend deploys typically do not need OIDC because Vercel has its own token system.
deploy-vercel:
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
- run: npm ci
- run: npm run build
- name: Deploy
run: npx vercel deploy --prebuilt --prod --token=$VERCEL_TOKEN
env:
VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
For preview deploys per pull request, swap --prod for nothing and post the preview URL back as a PR comment.
Deploying to Fly.io
Fly’s flyctl deploy reads fly.toml, builds and deploys. It pairs nicely with the build-once pattern via --image.
deploy-fly:
needs: build
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v4
- uses: superfly/flyctl-actions/setup-flyctl@master
- run: flyctl deploy --image ${{ needs.build.outputs.image }} --remote-only
env:
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
Fly handles rolling updates and health checks for you. The image is fetched from the registry you pushed it to in the build job.
Try it yourself. In a small repo, set up two environments: staging (no approval) and production (requires you as a reviewer). Wire a workflow that builds an image on every push to main, deploys it to staging, then has a deploy-prod job that needs the production environment. Push a commit and watch the run pause before the production job — approve it from the Actions UI.
Observability and rollback
Two things that pay for themselves the first time you need them:
Status checks back to GitHub. Use actions/github-script or the deployments API to mark deploys as in-progress, success, or failure. Combined with the environment URL, you get a one-click link to the live build from the Actions UI.
A rollback workflow. A small workflow with a workflow_dispatch trigger that takes a SHA as an input and re-runs the deploy job. When an incident hits, you do not want to be hand-rolling kubectl commands.
on:
workflow_dispatch:
inputs:
sha:
description: "Commit SHA to roll back to"
required: true
Secrets, hygiene, and the boring stuff
A few habits that keep deploys boring (the goal):
- Repo secrets for shared values, environment secrets for per-env values. Never reuse production credentials in staging jobs.
- Pin actions to a SHA, not a tag, for security-sensitive workflows. Tags can be rewritten; SHAs cannot.
- Use
concurrencykeys to prevent two production deploys racing each other.
concurrency:
group: deploy-production
cancel-in-progress: false
- Time out long jobs. A hung deploy that holds the queue is its own incident.
Try it yourself. Add a concurrency group on your production deploy job. Trigger two runs in quick succession (push two commits back-to-back). Watch the second one queue behind the first instead of racing. Then break a deploy on purpose (point the image tag at a non-existent SHA) and use your rollback workflow to restore the previous good build.
Recap
You now know:
- Environments give you per-target secrets and approval gates with one YAML key
- OIDC replaces long-lived cloud keys with short-lived tokens scoped to a repo
- Build once with a SHA tag and promote the same artifact through staging and production
- Deploy-on-main suits continuous deployment; deploy-on-tag suits release-based shipping
- Concrete deploy steps for AWS (ECR + ECS), Vercel, and Fly.io all follow the same general shape
- A rollback workflow and concurrency keys turn outages into routine pages
The hardest part of CI/CD is not the YAML — it is convincing the team to actually trust the pipeline. Small, frequent deploys with cheap rollbacks build that trust faster than any tooling change.
Next steps
If your deploy target is Kubernetes, the next post in this series will land your image into a cluster behind an Ingress.
Useful adjacent reading:
- GitHub Actions: Your First Workflow — for the YAML basics
- Kubernetes Ingress: Routing External Traffic — if your target is K8s
- AWS EC2 Basics — if you are deploying to plain VMs first
Questions or feedback? Email codeloomdevv@gmail.com.