# GitHub Actions for Beginners: GitHub Actions Deployments: Environments, Approvals, and Cloud Releases

Who decides when your code goes to production?

In the first three parts we built a pipeline that tests your code, runs only when it should, and holds no more access than it needs. All of that happens *before* the part that matters most: getting the code in front of users. Releasing is the step where a mistake costs the most, so it’s worth being deliberate about it.

Safe deployments come down to four questions:

- ***Where does the code go?*** That’s environments, such as staging and production.
- ***Who has to approve it?*** That’s protection rules and reviewers.
- ***How does the pipeline log in to your cloud without a stored password?*** That’s OpenID Connect (OIDC).
- ***What do you do when a release goes wrong?*** That’s rollbacks.

This post answers each one. By the end, you’ll have a pipeline that builds your app, deploys it to staging automatically, waits for a person to approve, and then deploys to production.

**What a deployment pipeline looks like:**

Back in Part 1 we met continuous delivery and continuous deployment. In practice, most teams use a flow like this:

![](https://cdn.hashnode.com/uploads/covers/6ab407e4ef329dc6a7ba7434/e2c32215-90ae-47cc-b202-4596451ccec9.png align="center")

Two ideas hold this together.

***Build once, deploy many.*** The image that goes to production should be exactly the image you tested on staging, not a fresh rebuild that happens to use the same code. We build it a single time, tag it with the commit, and deploy that same tag everywhere.

***Promote, don’t repeat.*** Staging is a rehearsal. Production only happens after staging succeeded, and after a person said yes.

## **1\. Environments:**

An environment is a named place your code gets deployed to, such as `staging` or `production`. In GitHub, an environment is also a set of rules and settings attached to that name:

- Protection rules that decide who may deploy and when.
- Secrets and variables that exist only for that environment, so the production credentials never appear in a staging job.
- A deployment history, shown on the repository’s home page and in its Deployments view.

*Creating one:* Go to ***Settings → Environments → New environment****.* Create two: `staging` and `production`. The names are your choice, but you’ll use them in the workflow and in your cloud setup, so keep them short and consistent.

![](https://cdn.hashnode.com/uploads/covers/6ab407e4ef329dc6a7ba7434/5635ec08-ee3d-43de-8ab8-4225437fd19c.png align="center")

*Using one in a workflow:* A job opts in with the `environment`key.

```yaml
jobs:
 deploy-production:
 runs-on: ubuntu-latest
 environment: production
 steps:
 - run: echo "Deploying to production"
```

## **2\. Protection rules:**

![](https://cdn.hashnode.com/uploads/covers/6ab407e4ef329dc6a7ba7434/84975572-6095-4cac-8795-7c6481241f00.png align="center")

*Check your plan first:* Availability of these features depends on your GitHub plan and on whether the repository is public or private. At the time of writing, GitHub’s documentation lists the full set for public repositories on every plan. For private repositories, required reviewers and wait timers are only listed for higher plans, while branch restrictions and environment secrets are available more widely. Plans change, so check ***Settings → Environments*** in your repository and GitHub’s current documentation before you build a process around approvals.

If your private repository doesn’t offer required reviewers, you can still protect production by deploying only from protected tags or by making it a manually triggered workflow, so that deploying is always a deliberate human action.

*Approvals:* Here’s what happens when a job reaches an environment that requires approval.

1. The workflow runs up to that job, then **pauses** with the status “Waiting”.
2. GitHub notifies the required reviewers.
3. A reviewer opens the run, clicks **Review deployments**, ticks the environment, and chooses **Approve and deploy** or **Reject**, with an optional comment.
4. On approval, the job starts. On rejection, it fails.

One detail matters a lot for security: **the job doesn’t start, and the environment’s secrets stay locked, until it’s approved.** The production credentials stay out of reach until a person says yes.

![](https://cdn.hashnode.com/uploads/covers/6ab407e4ef329dc6a7ba7434/80295ed5-5e84-4aa4-9278-c2a2d57615b1.png align="center")

## **3\. Passwordless cloud login with OIDC:**

To deploy to a cloud provider, the pipeline has to prove who it is. The traditional way is to create access keys, store them as secrets, and hope they never leak. Those keys are long-lived, so if they escape, they work until somebody notices and rotates them.

**OpenID Connect (OIDC)** removes the stored keys. Instead:

1. Each job can ask GitHub for a short-lived identity token that says exactly which repository, branch, and environment the job is running for.
2. The cloud provider has been told, ahead of time, “trust tokens from GitHub that say *this repository* and *this environment*.”
3. The job swaps the token for temporary cloud credentials that expire when the job ends.

There’s nothing to leak and nothing to rotate. The identity token’s sub (“subject”) claim is what the cloud provider checks. Its format depends on how the job was started:

![](https://cdn.hashnode.com/uploads/covers/6ab407e4ef329dc6a7ba7434/369530d4-66d6-45c6-9f6d-fe8b8782c91f.png align="center")

This is where the environment and OIDC come together. If your production role only trusts `repo:OWNER/REPO:environment:production`, then **only a job that went through the production environment, including its approval, can ever get production credentials.**

***Setting it up on AWS:*** I’ll use AWS as the example. Azure and Google Cloud have the same idea. Check each provider’s documentation for the exact steps.

On the AWS side, you do this once:

1. In IAM, add an **OpenID Connect identity provider** with the URL `https://token.actions.githubusercontent.com` and the audience `sts.amazonaws.com`.
2. Create an IAM role that GitHub can assume, using a trust policy like the one below, and attach only the permissions deployment needs.
3. Copy the role’s ARN.

Here’s the trust policy. Replace `OWNER/REPO` with your repository, and the account number with your own:

```json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Federated": "arn:aws:iam::<YOUR_AWS_ACCOUNT_ID>:oidc-provider/token.actions.githubusercontent.com"
      },
      "Action": "sts:AssumeRoleWithWebIdentity",
      "Condition": {
        "StringEquals": {
          "token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
          "token.actions.githubusercontent.com:sub": "repo:OWNER/REPO:environment:production"
        }
      }
    }
  ]
}
```

Make the `sub`condition as specific as you can. GitHub’s own example uses a wildcard (`repo:OWNER/REPO:*`), which lets any branch, pull request, or environment in the repository assume the role. That’s convenient for a first test and too broad for production.

***Using it in the workflow:*** In GitHub, open the `production` environment and add a variable/secret named `AWS_ROLE_ARN`. Create the same variables in staging, with the staging role’s ARN. Each environment gets its own values, so the same workflow file works for both.

Then, in the job:

```yaml
permissions:
  contents: read
  id-token: write      # lets the job request the identity token

steps:
  - name: Log in to AWS
    uses: aws-actions/configure-aws-credentials@e1253824e5c10ff9df46874f81ed3ec929e19cfd # v6.3.0
    with:
      role-to-assume: ${{ vars.AWS_ROLE_ARN }}
      aws-region: us-east-1
```

In short, what happens when you push to main:

- ***A README-only change:*** nothing runs, thanks to paths-ignore.
- ***A code change:*** the image is built, smoke-tested, and pushed. Staging deploys automatically.
- ***Staging succeeds:*** the production job shows “Waiting”. A reviewer approves it, the production credentials unlock, and the job runs.
- ***Staging fails:*** production never starts.

## **4\. Deploying safely:**

A few habits make releases boring, which is what you want.

***Never cancel a deployment halfway:*** Using `cancel-in-progress: true` for test runs, where cancelling is harmless, but for deployments, it isn’t, because stopping a deploy midway can leave an environment half-updated. That’s why the deploy jobs use:

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

A deployment that’s already running is never cancelled. A newer run in the same group waits until it finishes, so deployments happen one at a time.

![](https://cdn.hashnode.com/uploads/covers/6ab407e4ef329dc6a7ba7434/f65332bc-d583-49c8-8e73-e08a42a5d260.png align="center")

*Check that it worked:* A deployment that finished isn’t the same as a deployment that works. The health-check step calls the app’s `/health` endpoint, with retries to give it time to start. If that step fails, the job fails, and you hear about it before your users do.

*Have a way back:* Because every image is tagged with its commit, a rollback means deploying an older tag. We use manual way, it asks which environment and which tag, and it passes through the same approval rules as a normal deployment:

```yaml
on:
  workflow_dispatch:
    inputs:
      environment:
        description: Environment to roll back
        type: environment
        required: true
      image-tag:
        description: Seven-character commit tag of the image to restore
        type: string
        required: true
```

*You can also start it from your terminal:*

```shell
gh workflow run "Deploy with AWS Environment (OIDC)" -f environment="staging" -f image_tag="v1.0.0"
```

Don’t forget what’s in the registry: Images pile up in the registry. Decide how many you want to keep, so you can still roll back, without paying to store every build forever. Most registries support retention rules.

**Common errors and what they mean:**

![](https://cdn.hashnode.com/uploads/covers/6ab407e4ef329dc6a7ba7434/67472950-e78f-4d3e-bbfc-3ab808666a79.png align="center")

> *Safe releases come from making the careful path the automatic one: build once, test it, ask for approval, and keep a way back. With the full pipeline working, Part 5 shows how to remove the repetition with reusable workflows and take control of where your jobs run.*

**What’s next in the series:**

Part 1: GitHub Actions fundamentals and your first pipeline.

Part 2: Workflows in depth: triggers, conditions, environment variables, and debugging.

Part 3: Secrets, permissions, and securing your pipelines.

Part 4 (this post): Deployments: environments, approvals, and shipping to the cloud.

Part 5: Advanced patterns: reusable workflows, composite actions, and self-hosted runners.

Find all workflows used in this series on my [<u>GitHub Actions: Zero to Production</u>](https://github.com/Adithya-Meda/GitHub-Actions-Zero-to-Production) repository.\
