GitLab CI Deploy to AWS
Learn GitLab CI deploy AWS patterns including CI/CD variables, AWS OIDC, S3 deployments, EC2 SSH deploys, ECR image pushes, ECS service updates, and Terraform automation.
Deploying to AWS from GitLab CI/CD is one of the most common real-world automation patterns in modern DevOps. GitLab builds and tests your application, and AWS provides the infrastructure or managed platform where the application runs. When combined well, you get repeatable, auditable, environment-aware deployments that do not rely on someone manually running commands from a laptop.
A GitLab CI deploy to AWS pipeline can be simple or advanced. At the simple end, a pipeline might upload a static website to Amazon S3. At the advanced end, the pipeline might build a container image, push it to Amazon ECR, update an ECS task definition, roll a new service deployment, and manage supporting infrastructure with Terraform.
The foundation of all of these patterns is the same: GitLab CI needs a secure way to authenticate to AWS. Once authentication is solved, the rest becomes a matter of pipeline design.
Overview of Deploying to AWS from GitLab CI
There is no single “AWS deployment” workflow. AWS offers many targets, and GitLab can drive all of them. Common patterns include:
- deploying static assets to Amazon S3
- copying a release bundle to EC2 and restarting a service
- building a Docker image and pushing it to Amazon ECR
- updating a service in Amazon ECS
- provisioning or updating infrastructure with Terraform
- separating dev, staging, and production deployments across different accounts
Your choice depends on the application architecture.
- A static documentation site often goes to S3.
- A traditional VM-based app may deploy to EC2.
- A containerized microservice commonly uses ECR + ECS.
- Infrastructure teams often wrap all of the above with Terraform.
AWS Credential Management: Access Keys vs OIDC
The most important design decision is how GitLab CI will obtain AWS credentials.
Option 1: Long-lived access keys
The older pattern is to create an IAM user, generate an AWS access key ID and AWS secret access key, and store them as GitLab CI/CD variables. This works, but it has drawbacks:
- the secret is long-lived
- rotation is a manual operational task
- leaked keys are highly dangerous
- scope is often broader than necessary
- teams sometimes reuse the same key across environments
Long-lived keys are acceptable for small labs or legacy pipelines, but they are no longer the best default.
Option 2: OIDC with short-lived credentials
A better modern pattern is OIDC (OpenID Connect). With OIDC, GitLab issues an identity token to the job, AWS trusts that token through an IAM OIDC provider, and the job assumes an IAM role using temporary credentials.
That is better because:
- no long-lived AWS secret is stored in GitLab
- credentials are short-lived
- trust can be scoped to a specific project, branch, or protected ref
- revocation and permission changes happen centrally in IAM roles
- it aligns with cloud security best practices
In short, if you are designing a new GitLab CI deploy to AWS workflow, OIDC is usually the better choice.
Using AWS Access Keys in GitLab CI/CD Variables
If you must use access keys, store them in GitLab CI/CD variables rather than hardcoding them in .gitlab-ci.yml.
The common variables are:
AWS_ACCESS_KEY_IDAWS_SECRET_ACCESS_KEYAWS_DEFAULT_REGION
In GitLab:
- open Settings > CI/CD > Variables
- add each variable
- mark secrets as Masked and Protected when appropriate
- limit protected values to protected branches or tags for production
A simple job might look like this:
deploy_s3:
stage: deploy
image: amazon/aws-cli:2.17.40
script:
- aws sts get-caller-identity
- aws s3 sync dist/ s3://my-static-site --delete
The AWS CLI automatically reads AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, and AWS_DEFAULT_REGION from the environment.
This approach is straightforward, but remember the trade-off: the key is still a secret you must protect and rotate.
Using OIDC to Avoid Long-Lived Credentials
With GitLab OIDC, the pipeline job requests an ID token, AWS validates it, and the job assumes a role through sts:AssumeRoleWithWebIdentity.
At a high level, the setup looks like this:
- create GitLab as an OIDC provider in AWS IAM
- create an IAM role trusted by that provider
- restrict the role trust policy to the correct project, branch, or environment
- request an ID token in the GitLab job
- call AWS STS to exchange the token for temporary credentials
A simplified GitLab job looks like this:
assume_aws_role:
stage: deploy
image: amazon/aws-cli:2.17.40
id_tokens:
GITLAB_OIDC_TOKEN:
aud: sts.amazonaws.com
variables:
ROLE_ARN: arn:aws:iam::123456789012:role/gitlab-ecs-deploy
script:
- |
aws_sts_output=$(aws sts assume-role-with-web-identity --role-arn "$ROLE_ARN" --role-session-name "GitLabRunner-${CI_PROJECT_ID}-${CI_PIPELINE_ID}" --web-identity-token "$GITLAB_OIDC_TOKEN" --duration-seconds 3600 --query 'Credentials.[AccessKeyId,SecretAccessKey,SessionToken]' --output text)
- export $(printf "AWS_ACCESS_KEY_ID=%s AWS_SECRET_ACCESS_KEY=%s AWS_SESSION_TOKEN=%s" $aws_sts_output)
- aws sts get-caller-identity
This pattern is more secure because no long-lived AWS secret is stored in the project.
Deploying a Static Site to S3
One of the easiest AWS deployment targets is Amazon S3. If your GitLab pipeline builds a static site with HTML, CSS, JavaScript, or generated docs, you can publish it with aws s3 sync.
Example:
stages:
- build
- deploy
build_site:
stage: build
image: node:20
script:
- npm ci
- npm run build
artifacts:
paths:
- dist/
deploy_s3:
stage: deploy
image: amazon/aws-cli:2.17.40
needs:
- job: build_site
artifacts: true
script:
- aws s3 sync dist/ s3://my-static-site --delete
environment:
name: production
Why aws s3 sync is useful:
- it uploads new or changed files
- it removes old files when you use
--delete - it is simple and reliable for static content
Common additions include setting cache-control headers, invalidating CloudFront, or deploying to different buckets per environment.
Deploying to EC2 with SSH
Some teams still deploy to EC2 instances directly. In that model, GitLab builds the artifact, then connects over SSH to copy files and run deployment commands.
A common pattern is:
- build an application archive or artifact
- use
scporrsyncto copy it to the EC2 host - SSH into the host
- unpack files, install dependencies if needed, and restart the service
Example job:
deploy_ec2:
stage: deploy
image: alpine:3.20
before_script:
- apk add --no-cache openssh-client rsync
- mkdir -p ~/.ssh
- echo "$EC2_SSH_PRIVATE_KEY" > ~/.ssh/id_rsa
- chmod 600 ~/.ssh/id_rsa
- ssh-keyscan -H "$EC2_HOST" >> ~/.ssh/known_hosts
script:
- rsync -avz --delete dist/ ec2-user@$EC2_HOST:/var/www/myapp/
- ssh ec2-user@$EC2_HOST "sudo systemctl restart myapp && sudo systemctl status myapp --no-pager"
This works well for simple systems, but it comes with more server management overhead. You must manage patching, instance drift, service restarts, and rollback strategy yourself.
Building and Pushing to Amazon ECR
For containerized applications, Amazon ECR is a common image registry target. GitLab builds the Docker image, tags it, authenticates to ECR, and pushes the image.
A typical ECR push job looks like this:
build_and_push_ecr:
stage: package
image: docker:27
services:
- docker:27-dind
variables:
DOCKER_TLS_CERTDIR: "/certs"
IMAGE_TAG: $CI_COMMIT_SHORT_SHA
before_script:
- apk add --no-cache python3 py3-pip
- pip install awscli
- aws ecr get-login-password | docker login --username AWS --password-stdin "$AWS_ACCOUNT_ID.dkr.ecr.$AWS_DEFAULT_REGION.amazonaws.com"
script:
- docker build -t "$AWS_ACCOUNT_ID.dkr.ecr.$AWS_DEFAULT_REGION.amazonaws.com/myapp:$IMAGE_TAG" .
- docker push "$AWS_ACCOUNT_ID.dkr.ecr.$AWS_DEFAULT_REGION.amazonaws.com/myapp:$IMAGE_TAG"
Important ideas here:
- the image tag should be immutable, such as a commit SHA
- the ECR repository should already exist or be managed by Terraform
- you may also push a branch or release tag alias, but keep the SHA tag as the source of truth
Deploying to ECS
After the image is in ECR, the next step is often to deploy it to Amazon ECS. The general flow is:
- build and push a new image to ECR
- fetch the existing ECS task definition
- update the container image reference
- register a new task definition revision
- update the ECS service to use the new revision
A practical job might look like this:
deploy_ecs:
stage: deploy
image: amazon/aws-cli:2.17.40
script:
- yum install -y jq || true
- aws ecs describe-task-definition --task-definition myapp > task-def.json
- |
jq --arg IMAGE "$AWS_ACCOUNT_ID.dkr.ecr.$AWS_DEFAULT_REGION.amazonaws.com/myapp:$CI_COMMIT_SHORT_SHA" '.taskDefinition
| .containerDefinitions[0].image = $IMAGE
| del(.taskDefinitionArn,.revision,.status,.requiresAttributes,.compatibilities,.registeredAt,.registeredBy)' task-def.json > task-def-updated.json
- aws ecs register-task-definition --cli-input-json file://task-def-updated.json > new-task-def.json
- export TASK_DEF_ARN=$(jq -r '.taskDefinition.taskDefinitionArn' new-task-def.json)
- aws ecs update-service --cluster my-cluster --service myapp-service --task-definition "$TASK_DEF_ARN"
- aws ecs wait services-stable --cluster my-cluster --services myapp-service
This is a standard GitLab CI ECS deployment pattern. The exact details vary based on Fargate vs EC2 launch type, multiple containers, and environment-specific configuration.
Running Terraform from GitLab CI for AWS Infrastructure
Application deployment is only part of the picture. Many teams also use Terraform in GitLab CI to create or update AWS infrastructure.
Terraform jobs commonly do the following:
- initialize providers and modules
- validate configuration
- create a plan artifact
- require approval for production apply
- apply infrastructure changes to AWS
Example:
terraform_plan:
stage: deploy
image: hashicorp/terraform:1.9.8
script:
- terraform init
- terraform validate
- terraform plan -out=tfplan
artifacts:
paths:
- tfplan
terraform_apply:
stage: deploy
image: hashicorp/terraform:1.9.8
needs:
- job: terraform_plan
artifacts: true
script:
- terraform init
- terraform apply -auto-approve tfplan
when: manual
This is especially useful for managing:
- ECR repositories
- ECS clusters and services
- IAM roles and policies
- S3 buckets
- security groups and networking
Environment-Specific AWS Deployments
Most real systems have more than one environment. A good GitLab CI deploy to AWS design separates dev, staging, and prod with either different roles, different accounts, different buckets, or all three.
Common patterns include:
- different GitLab variables per environment
- separate AWS accounts for strong isolation
- protected branches controlling production deployment
- GitLab environments for visibility and approvals
Example:
deploy_staging:
stage: deploy
environment:
name: staging
rules:
- if: '$CI_COMMIT_BRANCH == "develop"'
variables:
ROLE_ARN: arn:aws:iam::111111111111:role/gitlab-staging-deploy
script:
- ./deploy.sh staging
deploy_production:
stage: deploy
environment:
name: production
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
when: manual
variables:
ROLE_ARN: arn:aws:iam::222222222222:role/gitlab-production-deploy
script:
- ./deploy.sh production
This creates a clean separation of permissions and release flow.
End-to-End Example: Build Docker Image, Push to ECR, Deploy to ECS
Here is a simplified end-to-end pipeline showing the most common container deployment flow.
stages:
- test
- package
- deploy
variables:
AWS_DEFAULT_REGION: us-east-1
AWS_ACCOUNT_ID: "123456789012"
ECR_REPOSITORY: myapp
ECS_CLUSTER: my-cluster
ECS_SERVICE: my-service
ECS_TASK_FAMILY: my-task
unit_tests:
stage: test
image: node:20
script:
- npm ci
- npm test
build_and_push_image:
stage: package
image: docker:27
services:
- docker:27-dind
before_script:
- apk add --no-cache python3 py3-pip
- pip install awscli
- aws ecr get-login-password | docker login --username AWS --password-stdin "$AWS_ACCOUNT_ID.dkr.ecr.$AWS_DEFAULT_REGION.amazonaws.com"
script:
- docker build -t "$AWS_ACCOUNT_ID.dkr.ecr.$AWS_DEFAULT_REGION.amazonaws.com/$ECR_REPOSITORY:$CI_COMMIT_SHA" .
- docker push "$AWS_ACCOUNT_ID.dkr.ecr.$AWS_DEFAULT_REGION.amazonaws.com/$ECR_REPOSITORY:$CI_COMMIT_SHA"
update_ecs_service:
stage: deploy
image: amazon/aws-cli:2.17.40
before_script:
- yum install -y jq || true
script:
- aws ecs describe-task-definition --task-definition "$ECS_TASK_FAMILY" > task-def.json
- |
jq --arg IMAGE "$AWS_ACCOUNT_ID.dkr.ecr.$AWS_DEFAULT_REGION.amazonaws.com/$ECR_REPOSITORY:$CI_COMMIT_SHA" '.taskDefinition
| .containerDefinitions[0].image = $IMAGE
| del(.taskDefinitionArn,.revision,.status,.requiresAttributes,.compatibilities,.registeredAt,.registeredBy)' task-def.json > task-def-updated.json
- aws ecs register-task-definition --cli-input-json file://task-def-updated.json > new-task-def.json
- export TASK_DEF_ARN=$(jq -r '.taskDefinition.taskDefinitionArn' new-task-def.json)
- aws ecs update-service --cluster "$ECS_CLUSTER" --service "$ECS_SERVICE" --task-definition "$TASK_DEF_ARN"
- aws ecs wait services-stable --cluster "$ECS_CLUSTER" --services "$ECS_SERVICE"
environment:
name: production
when: manual
This pipeline demonstrates a complete GitLab CI AWS deployment path:
- test the app
- build a Docker image
- push the image to ECR
- update ECS to use the new image
- wait for the service rollout to stabilize
That pattern is widely used because it is explicit, auditable, and easy to extend.
Practical Guidance and Common Pitfalls
When building GitLab CI pipelines for AWS, keep these best practices in mind:
- prefer OIDC over long-lived IAM user access keys
- scope IAM roles narrowly to the environment and AWS actions required
- tag container images with commit SHAs for traceability
- protect production variables and environments
- use manual gates for high-risk production deploys
- keep infrastructure creation in Terraform where possible
- avoid mixing dev and prod AWS credentials in the same variable set
Common mistakes include:
- storing AWS keys directly in the repository
- using one all-powerful IAM principal for every environment
- deploying mutable
latestimages without a traceable version - skipping a staging environment for DAST, smoke tests, or rollout checks
- giving GitLab CI more permissions than the deployment job actually needs
Final Takeaway
A GitLab CI deploy to AWS workflow can target S3, EC2, ECR, ECS, and Terraform-managed infrastructure, but the most important design choice is authentication. Long-lived access keys are simple but risky. OIDC is usually the better answer because it removes stored cloud secrets and uses short-lived credentials. From there, you can choose the deployment pattern that matches your application architecture and build reliable environment-aware pipelines.
If you want a strong default for modern apps, the most common path is: GitLab CI tests the app, builds a Docker image, pushes it to ECR, and deploys a new ECS task definition using OIDC-based AWS credentials.
Knowledge Check
Question 1: AWS Authentication
Why is OIDC usually better than storing AWS access keys in GitLab CI/CD variables?
Question 2: Static Site Deployment
Which AWS CLI command is commonly used in GitLab CI to publish a static site build directory to S3?
Question 3: ECR to ECS Flow
In a container-based GitLab CI deploy to AWS workflow, what usually happens after pushing the new image to Amazon ECR?