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.
There is no single âAWS deploymentâ workflow. AWS offers many targets, and GitLab can drive all of them. Common patterns include:
Your choice depends on the application architecture.
The most important design decision is how GitLab CI will obtain AWS credentials.
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:
Long-lived keys are acceptable for small labs or legacy pipelines, but they are no longer the best default.
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:
In short, if you are designing a new GitLab CI deploy to AWS workflow, OIDC is usually the better choice.
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_REGIONIn GitLab:
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 --deleteThe 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.
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:
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-identityThis pattern is more secure because no long-lived AWS secret is stored in the project.
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: productionWhy aws s3 sync is useful:
--deleteCommon additions include setting cache-control headers, invalidating CloudFront, or deploying to different buckets per environment.
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:
scp or rsync to copy it to the EC2 hostExample 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.
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:
After the image is in ECR, the next step is often to deploy it to Amazon ECS. The general flow is:
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-serviceThis 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.
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:
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: manualThis is especially useful for managing:
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:
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 productionThis creates a clean separation of permissions and release flow.
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: manualThis pipeline demonstrates a complete GitLab CI AWS deployment path:
That pattern is widely used because it is explicit, auditable, and easy to extend.
When building GitLab CI pipelines for AWS, keep these best practices in mind:
Common mistakes include:
latest images without a traceable versionA 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.
<MCQExercise exercise={{ id: 'gitlab-aws-q1', title: 'Question 1: AWS Authentication', description: "Why is OIDC usually better than storing AWS access keys in GitLab CI/CD variables?", options: [ { id: 'a', text: 'OIDC creates short-lived credentials and avoids long-lived AWS secrets' }, { id: 'b', text: 'OIDC removes the need for IAM roles in AWS' }, { id: 'c', text: 'OIDC only works for S3 and cannot be used for ECS' }, { id: 'd', text: 'OIDC makes every deployment manual by default' }, ], correctId: 'a', explanation: "OIDC lets GitLab jobs assume an AWS role dynamically, which avoids storing a long-lived secret and reduces the blast radius of credential exposure.", wrongExplanations: { b: 'OIDC still relies on IAM roles; it changes how the job obtains credentials.', c: 'OIDC can be used for many AWS services, including ECR, ECS, S3, and Terraform workflows.', d: 'Manual vs automatic deployment is a pipeline design choice, not a property of OIDC.', }, }} />
<MCQExercise exercise={{ id: 'gitlab-aws-q2', title: 'Question 2: Static Site Deployment', description: "Which AWS CLI command is commonly used in GitLab CI to publish a static site build directory to S3?", options: [ { id: 'a', text: 'aws ecs update-service' }, { id: 'b', text: 'aws s3 sync dist/ s3://my-bucket --delete' }, { id: 'c', text: 'aws iam create-role' }, { id: 'd', text: 'aws ecr batch-delete-image' }, ], correctId: 'b', explanation: "The aws s3 sync command is the standard way to upload changed files to a bucket and optionally remove old files with the --delete flag.", wrongExplanations: { a: 'That command is for ECS service updates, not static website publishing.', c: 'Creating IAM roles is an infrastructure task, not a site deployment command.', d: 'Deleting images from ECR is unrelated to publishing static assets to S3.', }, }} />
<MCQExercise exercise={{ id: 'gitlab-aws-q3', title: 'Question 3: ECR to ECS Flow', description: "In a container-based GitLab CI deploy to AWS workflow, what usually happens after pushing the new image to Amazon ECR?", options: [ { id: 'a', text: 'GitLab automatically updates every ECS service in the account' }, { id: 'b', text: 'The pipeline typically registers a new ECS task definition revision and updates the ECS service' }, { id: 'c', text: 'The image becomes live in production without any deployment step' }, { id: 'd', text: 'The pipeline must convert the Docker image into an S3 website bundle' }, ], correctId: 'b', explanation: "ECS deployments usually involve updating the task definition to reference the new ECR image and then telling the ECS service to roll out that new revision.", wrongExplanations: { a: 'GitLab does not update every ECS service automatically; you must target the correct cluster and service.', c: 'Pushing to ECR only stores the image. A deployment step is still needed to make ECS use it.', d: 'S3 website bundles are a different deployment model entirely.', }, }} />
Create an IAM OIDC identity provider in your AWS account pointing at your GitLab instance, then create an IAM role whose trust policy accepts tokens from that provider with conditions restricting which project, and ideally which branch or environment, may assume it. In the pipeline, request an ID token and exchange it with aws sts assume-role-with-web-identity. The conditions in the trust policy are the important part, without scoping to a specific project path, any project on the instance could assume the role.
Only the actions that specific deployment needs, scoped to specific resource ARNs. A static site deploy needs S3 object write on one bucket and a CloudFront invalidation on one distribution; an ECS deploy needs to register a task definition and update one service. Starting from a broad managed policy with the intention of narrowing later almost never gets narrowed. Separate roles per environment are worth the extra setup, so a staging pipeline cannot touch production.
Sync the built files to the bucket with the AWS CLI, then invalidate the CloudFront distribution so viewers get the new version rather than a cached one. Two details matter: sync with --delete so removed files actually disappear rather than lingering, and set cache headers deliberately, long-lived caching for fingerprinted assets, short or no caching for index.html. Skipping the invalidation is the usual reason a deploy appears to succeed while users keep seeing the old site.
Because it is running as a different identity. Your laptop uses your personal credentials, which are typically far broader than the deploy role. Run aws sts get-caller-identity as the first line of the job to see exactly which principal the pipeline is using, then compare that against the policy. The other frequent cause is region: the CLI in CI has no configured default, so a command targeting a resource in one region while the CLI defaults to another fails in a way that reads like a permissions problem.
Yes, and it is a natural fit, but use a remote backend with state locking, typically S3 with DynamoDB or GitLab's own managed Terraform state, because local state in a job is discarded when the container exits. Run terraform plan on merge requests so the change is reviewable, and gate terraform apply behind a manual job on the default branch. Never let apply run automatically on a feature branch against shared infrastructure.
Trigger downstream pipelines in GitLab with the trigger keyword: pass variables, choose strategy depend or mirror, build child pipelines and use include.
Speed up GitLab CI pipelines with better caching, DAG execution using needs, reusable include templates, YAML anchors, rules and protected deployments.
Learn what CI/CD means: how continuous integration, delivery and deployment differ, why teams need automation and what a modern pipeline looks like.