How to set up development and production environments using AWS Copilot: Example using a `plumber` API.

In this post I am documenting step-by-step the process of deploying dev/stage/prod environments and instances of a {plumber} API on AWS AppRunner using AWS Copilot. This is an expanded follow up to a previous post on the topic.

Prerequisites

To manage AWS resources, we need the AWS command line interface (cli). To build our infrastructure we’ll use AWS Copilot. Follow instructions for installation here. Copilot is a command line interface for containerized applications, so we’ll also need a tool to containerize our {plumber} API. Typically docker which can be installed following these instructions.

AWS Access and Services

The process described below, including the permissions policies, makes minimal assumptions about the permissions a user will have on AWS. It should be enough to get us started even if we did not have access to any of the needed services. Though, of course we’ll still require an account administrator to grant us the access by attaching policies to our AWS user or group.

During setup and deployment AWS Copilot requires access to multiple AWS services:

  • AWS Identity and access management (IAM)
  • AWS Elastic container registry (ECR)
  • AWS Cloud formation (CNF)
  • AWS Simple storage service (S3)
  • AWS Security token service (STS)
  • AWS Key management service (KMS)
  • AWS Systems manager (SSM)
  • AWS Tags manager (TAG)

AWS Setup

Assume user with minimal permissions. Added policies will be documented below. AWS Copilot uses the AWS_PROFILE environmental variable and assumes the aws cli has been configured. When properly configured, our development machine will have an .aws folder with config and credentials files defining the different users, regions, aws_secret_access_key and aws_secret_key_id.

export $AWS_PROFILE=noob

Docker images

Base image (Dockerfile_base)

This builds the base image, setting up the R environment and dependencies for the API. Assuming dependencies will not change often, this image can be pushed to ECR once and then used to rebuild the API image as the API evolves. If dependencies change, this image would have to be rebuilt and pushed to ECR.

docker build -d Dockerfile_base -t "myapi_base" .

The code below tags the image with the name provided by AWS when we create the registry for the base image. Then, it obtains AWS ECR login credentials and pushes the local image to AWS ECR. This makes it available for AWS Copilot, as it is needed when AWS Copilot builds our API service docker image.

docker tag myapi_base <aws_account_number>.dkr.ecr.<aws_region>.amazonaws.com/myapi_base
aws ecr get-login-password | \
  docker login -u AWS --password-stdin \
  <aws_account_number>.dkr.ecr.<aws_region>.amazonaws.com/myapi

Service image (Dockerfile)

Make sure its FROM instruction is the base registry above

FROM <aws_account_number>.dkr.ecr.<aws_region>.amazonaws.com/myapi_base
RUN installr -d remotes
RUN mkdir /build_zone
ADD . /build_zone
WORKDIR /build_zone
RUN R -e 'remotes::install_local(upgrade="never")'
RUN rm -rf /build_zone
EXPOSE 5050
CMD  ["R", "-e", "library(myapi); run_api(port = 5050, host = '0.0.0.0')"]

Initialize AWS resources

Initialize the application with aws copilot

copilot app init myapi-api

Initialize environments:

copilot env init --name dev --profile noob
copilot env init --name stage --profile noob
copilot env init --name prod --profile noob

At this point the deployment copilot directory will look like so:

copilot/
├── environments
│   ├── dev
│   │   └── manifest.yml
│   ├── prod
│   │   └── manifest.yml
│   └── stage
│       └── manifest.yml
└── .workspace

Deploy

For each environment, copilot will first deploy the environment using CloudFromation, and then push build and push the Docker image to Elastic Container Registry, and finally configure AppRunner to make the service available.

Dev env

copilot init -d ./Dockerfile --app myapi-api -n myapi -t "Request-Driven Web Service" -e dev

Stage env

copilot init -d ./Dockerfile --app myapi-api -n myapi -t "Request-Driven Web Service" -e stage

Prod env

copilot init -d ./Dockerfile --app myapi-api -n myapi -t "Request-Driven Web Service" -e prod

Secrets

Create the secret

copilot secret init
# follow promts

Update the application manifest, should look like this:

# You can override any of the values defined above by environment.
environments:
  dev:
    variables:
      LOG_LEVEL: debug # Log level for the "test" environment.
    secrets:
      SECRET: /copilot/myapi-api/dev/secrets/SECRET
  stage:
    secrets:
      SECRET: /copilot/myapi-api/stage/secrets/SECRET
  prod:
    secrets:
      SECRET: /copilot/myapi-api/prod/secrets/SECRET

Redeploy the service instance for each env

copilot svc deploy --env dev
copilot svc deploy --env stage
copilot svc deploy --env prod

AWS permission policies for used services

CloudFormation

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "cloudformation:DescribeStackSet",
                "cloudformation:CreateStack",
                "cloudformation:GetTemplate",
                "cloudformation:DescribeStackSetOperation",
                "cloudformation:DeleteStack",
                "cloudformation:UpdateStack",
                "cloudformation:DescribeStackResource",
                "cloudformation:UpdateStackSet",
                "cloudformation:CreateChangeSet",
                "cloudformation:DescribeChangeSet",
                "cloudformation:DeleteStackSet",
                "cloudformation:DescribeStacks",
                "cloudformation:TagResource",
                "cloudformation:GetTemplateSummary",
                "cloudformation:ListStackInstances",
                "cloudformation:CreateStackInstances",
                "cloudformation:ExecuteChangeSet",
                "cloudformation:DescribeStackEvents"
            ],
            "Resource": [
                "arn:aws:cloudformation:*:<aws_account_number>:type/resource/*",
                "arn:aws:cloudformation:*:<aws_account_number>:stackset-target/*",
                "arn:aws:cloudformation:*:<aws_account_number>:stackset/*:*",
                "arn:aws:cloudformation:*:<aws_account_number>:stack/*/*"
            ]
        },
        {
            "Effect": "Allow",
            "Action": [
                "cloudformation:CreateGeneratedTemplate",
                "cloudformation:ListStacks",
                "cloudformation:UpdateGeneratedTemplate",
                "cloudformation:ListStackSets",
                "cloudformation:DescribeGeneratedTemplate",
                "cloudformation:CreateStackSet",
                "cloudformation:ValidateTemplate"
            ],
            "Resource": "*"
        }
    ]
}

ECR

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "ecr:BatchGetImage",
        "ecr:CompleteLayerUpload",
        "ecr:DescribeImages",
        "ecr:TagResource",
        "ecr:DescribeRepositories",
        "ecr:BatchDeleteImage",
        "ecr:UploadLayerPart",
        "ecr:ListImages",
        "ecr:InitiateLayerUpload",
        "ecr:DeleteRepository",
        "ecr:BatchCheckLayerAvailability",
        "ecr:PutImage"
      ],
      "Resource": "arn:aws:ecr:*:<aws_account_number>:repository/*"
    },
    {
      "Effect": "Allow",
      "Action": [
        "ecr:CreateRepository",
        "ecr:DescribeRegistry",
        "ecr:GetAuthorizationToken"
      ],
      "Resource": "*"
    }
  ]
}

IAM

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "iam:GetRole",
        "iam:UpdateAssumeRolePolicy",
        "iam:ListRoleTags",
        "iam:GetPolicy",
        "iam:TagRole",
        "iam:CreateRole",
        "iam:PutRolePolicy",
        "iam:PassRole",
        "iam:CreateServiceLinkedRole",
        "iam:ListAttachedRolePolicies",
        "iam:UpdateRole",
        "iam:ListPolicyTags",
        "iam:ListRolePolicies",
        "iam:GetRolePolicy"
      ],
      "Resource": [
        "arn:aws:iam::<aws_account_number>:role/*",
        "arn:aws:iam::<aws_account_number>:policy/*"
      ]
    },
    {
      "Effect": "Allow",
      "Action": [
        "iam:ListPolicies",
        "iam:ListRoles"
      ],
      "Resource": "*"
    }
  ]
}

KMS

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "kms:Decrypt",
        "kms:GenerateDataKey",
        "kms:DescribeKey"
      ],
      "Resource": "arn:aws:kms:*:<aws_account_number>:key/*"
    }
  ]
}

S3

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "s3:PutObject",
        "s3:GetObjectAcl",
        "s3:GetObject",
        "s3:DeleteObject",
        "s3:PutObjectAcl"
      ],
      "Resource": "arn:aws:s3:::*/*"
    },
    {
      "Effect": "Allow",
      "Action": [
        "s3:PutBucketAcl",
        "s3:CreateBucket",
        "s3:ListBucket",
        "s3:GetBucketAcl",
        "s3:DeleteBucket"
      ],
      "Resource": "arn:aws:s3:::*"
    }
  ]
}

STS

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "sts:AssumeRole",
        "sts:AssumeRoleWithWebIdentity"
      ],
      "Resource": "arn:aws:iam::<aws_account_number>:role/*"
    },
    {
      "Effect": "Allow",
      "Action": "sts:GetCallerIdentity",
      "Resource": "*"
    }
  ]
}

SystemsManager

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "ssm:PutParameter",
        "ssm:GetParametersByPath",
        "ssm:GetParameters",
        "ssm:GetParameter",
        "ssm:AddTagsToResource"
      ],
      "Resource": "arn:aws:ssm:<aws_region>:<aws_account_number>:parameter/*"
    },
    {
      "Effect": "Allow",
      "Action": "ssm:DescribeParameters",
      "Resource": "*"
    }
  ]
}

TAG (Tag editor)

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "tag:GetResources",
        "tag:GetTagValues",
        "tag:GetTagKeys"
      ],
      "Resource": "*"
    }
  ]
}

GH Actions

Use this tutorial to create a IAM role for GitHub Actions: https://aws.amazon.com/blogs/security/use-iam-roles-to-connect-github-actions-to-actions-in-aws/

Policy for GH Actions role

The policies for GH Actions has reduced permissions. It is added to the role created above.

Trust Relationship

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Principal": {
                "Federated": "arn:aws:iam::<aws_account_number>:oidc-provider/token.actions.githubusercontent.com"
            },
            "Action": "sts:AssumeRoleWithWebIdentity",
            "Condition": {
                "StringEquals": {
                    "token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
                },
                "StringLike": {
                    "token.actions.githubusercontent.com:sub": "repo:myorg/myrepo:ref:refs/*"
                }
            }
        }
    ]
}

STS

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "sts:AssumeRole",
        "sts:GetCallerIdentity",
        "sts:AssumeRoleWithWebIdentity"
      ],
      "Resource": "*"
    }
  ]
}

Deploy

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Resource": "arn:aws:ssm:<aws_region>:<aws_account_number>:parameter/copilot/*",
      "Effect": "Allow",
      "Action": [
        "ssm:GetParametersByPath",
        "ssm:GetParameter"
      ]
    },
    {
      "Resource": "arn:aws:cloudformation:<aws_region>:<aws_account_number>:stackset/myapi-infrastructure:*",
      "Effect": "Allow",
      "Action": [
        "cloudformation:ListStackInstances"
      ]
    },
    {
      "Resource": "arn:aws:cloudformation:<aws_region>:<aws_account_number>:stack/*",
      "Effect": "Allow",
      "Action": [
        "cloudformation:DescribeStacks"
      ]
    },
    {
      "Resource": "*",
      "Effect": "Allow",
      "Action": [
        "ecr:GetAuthorizationToken"
      ]
    },
    {
      "Resource": [
        "arn:aws:ecr:<aws_region>:<aws_account_number>:repository/myapi/*",
        "arn:aws:ecr:<aws_region>:<aws_account_number>:repository/myapi_base"
      ],
      "Effect": "Allow",
      "Action": [
        "ecr:InitiateLayerUpload",
        "ecr:UploadLayerPart",
        "ecr:CompleteLayerUpload",
        "ecr:PutImage",
        "ecr:BatchCheckLayerAvailability",
        "ecr:BatchGetImage",
        "ecr:GetDownloadUrlForLayer"
      ]
    }
  ]
}

Deploy to dev/stage GH Action

Deploy to dev/stage is triggered by push or pull request to the corresponding branches.

# This is a basic workflow to help you get started with Actions
name: Connect to an AWS role from a GitHub repository

# Controls when the action will run. Invokes the workflow on push events but only for the main branch
on:
  push:
    branches: [dev]
  pull_request:
    branches: [dev]

env:
  AWS_REGION: "<aws_region>" #Change to reflect your Region

# Permission can be added at job level or workflow level
permissions:
  id-token: write # This is required for requesting the JWT
  contents: read # This is required for actions/checkout
jobs:
  DeployService:
    runs-on: ubuntu-latest
    steps:
      - name: Git clone the repository
        uses: actions/checkout@v4
      - name: configure aws credentials
        uses: aws-actions/configure-aws-credentials@v1.7.0
        with:
          role-to-assume: arn:aws:iam::<aws_account_number>:role/GitHubAction-AssumeRoleWithAction #change to reflect your IAM role’s ARN
          role-session-name: GitHub_to_AWS_via_FederatedOIDC
          aws-region: ${{ env.AWS_REGION }}
      # Hello from AWS: WhoAmI
      # - name: Sts GetCallerIdentity
      #   run: |
      #     aws sts get-caller-identity
      - name: Install copilot
        run: |
          mkdir -p $GITHUB_WORKSPACE/bin
          # download copilot
          curl -Lo copilot-linux https://github.com/aws/copilot-cli/releases/latest/download/copilot-linux && \
          # make copilot bin executable
          chmod +x copilot-linux && \
          # move to path
          mv copilot-linux $GITHUB_WORKSPACE/bin/copilot && \
          # add to PATH
          echo "$GITHUB_WORKSPACE/bin" >> $GITHUB_PATH
          # - run: copilot help
      - name: deploy service
        run: copilot svc deploy --env dev

Deploy to prod GH Action

Deploy to PROD is triggered by manually creating a release in GitHub.

# This is a basic workflow to help you get started with Actions
name: Connect to an AWS role from a GitHub repository

# Controls when the action will run. Invokes the workflow on push events but only for the main branch
on:
  release:
    types: [published]

env:
  AWS_REGION: "<aws_region>" #Change to reflect your Region

# Permission can be added at job level or workflow level
permissions:
  id-token: write # This is required for requesting the JWT
  contents: read # This is required for actions/checkout
jobs:
  DeployService:
    runs-on: ubuntu-latest
    steps:
      - name: Git clone the repository
        uses: actions/checkout@v4
      - name: configure aws credentials
        uses: aws-actions/configure-aws-credentials@v1.7.0
        with:
          role-to-assume: arn:aws:iam::<aws_account_number>:role/GitHubAction-AssumeRoleWithAction #change to reflect your IAM role’s ARN
          role-session-name: GitHub_to_AWS_via_FederatedOIDC
          aws-region: ${{ env.AWS_REGION }}
      # Hello from AWS: WhoAmI
      # - name: Sts GetCallerIdentity
      #   run: |
      #     aws sts get-caller-identity
      - name: Install copilot
        run: |
          mkdir -p $GITHUB_WORKSPACE/bin
          # download copilot
          curl -Lo copilot-linux https://github.com/aws/copilot-cli/releases/latest/download/copilot-linux && \
          # make copilot bin executable
          chmod +x copilot-linux && \
          # move to path
          mv copilot-linux $GITHUB_WORKSPACE/bin/copilot && \
          # add to PATH
          echo "$GITHUB_WORKSPACE/bin" >> $GITHUB_PATH
          # - run: copilot help
      - name: deploy service
        run: copilot svc deploy --env prod

Summary

A step-by-step guide to set up dev/stage/prod environments on AWS for deploying a {plumber} API on AWS AppRunner and setting up GitHub Actions workflows for automated deployments on the created AWS infrastructure.

Teofil Nakov
Teofil Nakov

My interests include R, Shiny, Bioinformatics, and integrating these in the cloud