Deploy a Dockerized App to ECS Fargate

Build a GitLab CI/CD pipeline that deploys a Flask app to AWS ECS Fargate.

Introduction

30 Second Summary

When a developer pushes code at a company, they don't manually log into a server to deploy it. An automated pipeline handles everything from testing to deployment, so code goes live in minutes without anyone touching a server.

In this project, you will build a complete CI/CD pipeline that automatically tests, containerizes, and deploys a Python Flask application to AWS every time you push code to your repository.

What You'll Build

A GitLab CI/CD pipeline that takes a containerized Flask app from code push to production deployment on AWS ECS Fargate, with zero manual intervention.

By the end of this project, you'll have:

  • A Docker image stored in AWS ECR, tagged with the Git commit SHA for version traceability.
  • A live containerized application running on Fargate, accessible via a public IP address with zero server management.
  • A three-stage pipeline (test, build, deploy) that triggers automatically on every push to main.
  • Secret Mission: a manual rollback job that redeploys the previous image version with one click.

Are there any prerequisites?

This project assumes you have basic Docker knowledge (writing a Dockerfile, multi-stage builds) and basic GitLab CI/CD experience (stages, jobs, .gitlab-ci.yml). You also need a new or existing AWS account and a GitLab.com account.

Set Up Your AWS Infrastructure

Before your pipeline can deploy anything, it needs somewhere to push Docker images and somewhere to run containers.

In this step, you'll create three AWS resources that form the foundation of your deployment target: a container registry for storing images, a cluster for running containers, and an IAM user that gives your pipeline permission to interact with AWS.

In this step, get ready to:
  • Create an ECR repository to store your Docker images.
  • Create an ECS cluster with Fargate infrastructure.
  • Create an IAM user with deployment permissions for your pipeline.
Create an ECR repository

Amazon ECR (Elastic Container Registry) is a managed Docker registry that stores your container images. Your pipeline will push newly built images here, and ECS will pull from here when deploying.

  • Sign into the AWS Management Console with your AWS account.
  • In the search bar at the top, type ECR and select Elastic Container Registry.
  • Click Create repository.
  • Under Visibility settings, select Private.
  • In the Repository name field, enter nextwork-flask-app.
  • Leave all other settings as default and click Create repository.

You should see a success banner confirming your repository was created. The repository URI is displayed in the list. Take note of which AWS region you're working in (visible in the top-right of the console).

Why a private repository?

Private repositories require authentication to pull images. This prevents anyone on the internet from downloading your application code.

ECR private repositories also get 500 MB of free storage for 12 months on new AWS accounts.

Don't see the Create repository button?

Make sure you're on the Private tab of the ECR dashboard, not Public. If this is your first time using ECR, you may need to click Get Started first.

Still stuck? Help me find the ECR Create repository page.

Create an ECS cluster

AWS ECS (Elastic Container Service) orchestrates your containers. A cluster is the logical grouping where your containers run.

  • In the AWS Console search bar, type ECS and select Elastic Container Service.
  • Click Create cluster.
  • In the Cluster name field, enter nextwork-cluster.
  • Under Infrastructure, make sure AWS Fargate (serverless) is selected.
  • Leave all other settings as default and click Create.

You should see a success message and your cluster status should show as Active within a few seconds.

Why Fargate instead of EC2?

AWS Fargate is a serverless compute engine for containers. With Fargate, you don't manage any servers. AWS handles provisioning, scaling, and patching the underlying infrastructure.

This lets you focus entirely on your pipeline and deployment logic instead of maintaining EC2 instances.

Create an IAM user for pipeline access

Your GitLab pipeline needs credentials to interact with AWS. You'll create a dedicated IAM user with only the permissions the pipeline needs.

  • In the AWS Console search bar, type IAM and select IAM.
  • In the left sidebar, click Users.
  • Click Create user.
  • In the User name field, enter gitlab-cicd-deployer.
  • Do NOT check the box for Provide user access to the AWS Management Console. This user only needs programmatic access.
  • Click Next.

Now you'll attach permissions. The pipeline needs two sets of permissions: full ECR access (to push images) and specific ECS permissions (to deploy containers).

  • On the Set permissions page, select Attach policies directly.
  • In the search box, type AmazonEC2ContainerRegistryFullAccess and check the box next to it.
  • Click Next, then click Create user.

The user is created, but it still needs a custom policy for ECS deployment permissions. You'll create that now.

  • Click on the user name gitlab-cicd-deployer in the Users list to open its details.
  • Click the Permissions tab, then click Add permissions.
  • Select Create inline policy.
  • Click the JSON tab in the policy editor.
  • Replace the contents with the following policy, then click Next:
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "ecs:DescribeTaskDefinition",
                "ecs:RegisterTaskDefinition",
                "ecs:UpdateService",
                "ecs:DescribeServices",
                "iam:PassRole"
            ],
            "Resource": "*"
        }
    ]
}

What does this policy allow?

  • ecs:DescribeTaskDefinition lets the pipeline fetch the current task definition so it knows what to update.
  • ecs:RegisterTaskDefinition lets the pipeline create a new revision with the updated image.
  • ecs:UpdateService lets the pipeline tell ECS to switch to the new task definition.
  • iam:PassRole lets ECS assume the task execution role when launching your container.
  • In the Policy name field, enter ECSDeployPolicy.
  • Click Create policy.

Your user now has two policies: AmazonEC2ContainerRegistryFullAccess for pushing images to ECR, and ECSDeployPolicy for deploying to ECS.

The last thing you need is an access key so your pipeline can authenticate as this user.

  • On the gitlab-cicd-deployer user page, click the Security credentials tab.
  • Scroll down to Access keys and click Create access key.
  • Select Third-party service as the use case.
  • Check the confirmation checkbox acknowledging the recommendation, then click Next.
  • Click Create access key.

Save your credentials now

You will see an Access key ID and Secret access key on this page. Copy both values and save them somewhere secure (like a text file on your desktop).

This is the only time you can view the secret access key. If you lose it, you'll need to create a new access key.

  • Copy the Access key ID and paste it into a text file for safekeeping.
  • Click Show next to the Secret access key, copy it, and paste it into the same text file.
  • Click Done.

Accidentally closed the page without saving?

If you missed copying the secret access key, navigate back to the user's Security credentials tab. Delete the access key you just created, then create a new one. AWS never shows a secret access key twice.

Need help? Walk me through recreating my IAM access key.

Your AWS infrastructure is ready. You have a registry for images, a cluster for running containers, and a dedicated user with credentials your pipeline will use. Next up, you'll build the Flask application and Dockerfile that your pipeline will deploy.

Create the Flask App and Dockerfile

Your AWS infrastructure is ready. The ECR repository, ECS cluster, and IAM credentials are all set up and waiting for a Docker image to deploy.

Now you need the actual application. In this step, you'll create a Python Flask app, write tests for it, and package everything inside a Docker container optimized for production.

In this step, get ready to:
  • Create a Flask web application with a health check endpoint.
  • Write a multi-stage Dockerfile that produces a lean production image.
  • Build and test the container locally to confirm everything works.
Create the Flask application files

Your application needs three Python files. The main app serves web requests, a requirements file pins your dependencies, and a test file verifies the endpoints work before you deploy.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir nextwork-flask-app
cd nextwork-flask-app
  • Confirm the folder was created by running:
ls

You should see the nextwork-flask-app folder listed in the output.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type VS Code and press Enter to open it.
  • In VS Code, click File in the top menu bar.
  • Click Open Folder.
  • Select the nextwork-flask-app folder on your Desktop, then click Open.
  • Create a new file called app.py inside your nextwork-flask-app folder, then paste the following code:
from flask import Flask, jsonify
import os

app = Flask(__name__)

@app.route("/")
def home():
    return jsonify({
        "message": "Hello from NextWork! Deployed via GitLab CI/CD to ECS Fargate.",
        "version": os.environ.get("APP_VERSION", "1.0.0")
    })

@app.route("/health")
def health():
    return jsonify({"status": "healthy"}), 200

if __name__ == "__main__":
    app.run(host="0.0.0.0", port=5000)

What does this code do?

This Flask app defines two endpoints:

  • The / route returns a JSON message and reads an APP_VERSION environment variable (defaulting to 1.0.0 if not set). This lets you verify which version is deployed.
  • The /health route returns a simple status check. ECS uses health endpoints like this to know if your container is running properly.
  • Save app.py.
  • Create a new file called requirements.txt in the same folder, then paste the following:
flask==3.1.1
gunicorn==23.0.0
pytest==8.3.4

What are these dependencies?

  • Flask is the web framework that handles HTTP requests.
  • Gunicorn is a production-grade WSGI server. In production, you run Flask behind Gunicorn instead of Flask's built-in development server.
  • Pytest is the testing framework your pipeline will use to verify the app before deployment.
  • Save requirements.txt.
  • Create a new file called test_app.py in the same folder, then paste the following code:
import pytest
from app import app

@pytest.fixture
def client():
    app.config["TESTING"] = True
    with app.test_client() as client:
        yield client

def test_home(client):
    response = client.get("/")
    assert response.status_code == 200
    data = response.get_json()
    assert "message" in data

def test_health(client):
    response = client.get("/health")
    assert response.status_code == 200
    data = response.get_json()
    assert data["status"] == "healthy"

What does this test file do?

The @pytest.fixture creates a test client that can make HTTP requests to your Flask app without starting a real server.

Each test_ function sends a request to one endpoint and checks for the expected status code and response data. Your CI/CD pipeline will run these tests automatically before every deployment.

  • Save test_app.py.
  • In the VS Code Explorer sidebar, confirm you can see all three files: app.py, requirements.txt, and test_app.py.

Don't see all three files?

  • Make sure you saved each file after pasting the code.
  • Check that all files are inside the nextwork-flask-app folder, not a subfolder or the Desktop itself.
  • If VS Code shows the wrong folder in the sidebar, close it and reopen with File > Open Folder, selecting nextwork-flask-app from your Desktop.

Why aren't my files showing up in VS Code?

✔️ Awesome, I've got everything!

Great. Make sure all three files are saved before moving on.

ⓧ I'd like to double check the full code

from flask import Flask, jsonify
import os

app = Flask(__name__)

@app.route("/")
def home():
    return jsonify({
        "message": "Hello from NextWork! Deployed via GitLab CI/CD to ECS Fargate.",
        "version": os.environ.get("APP_VERSION", "1.0.0")
    })

@app.route("/health")
def health():
    return jsonify({"status": "healthy"}), 200

if __name__ == "__main__":
    app.run(host="0.0.0.0", port=5000)
flask==3.1.1
gunicorn==23.0.0
pytest==8.3.4
import pytest
from app import app

@pytest.fixture
def client():
    app.config["TESTING"] = True
    with app.test_client() as client:
        yield client

def test_home(client):
    response = client.get("/")
    assert response.status_code == 200
    data = response.get_json()
    assert "message" in data

def test_health(client):
    response = client.get("/health")
    assert response.status_code == 200
    data = response.get_json()
    assert data["status"] == "healthy"
Write the multi-stage Dockerfile

A multi-stage build separates the dependency installation from the final image. This keeps your production container small by excluding build tools and caches that are only needed during installation.

  • Create a new file called Dockerfile (no file extension) in the same folder, then paste the following:
# Build stage
FROM python:3.12-slim AS builder

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt

# Production stage
FROM python:3.12-slim

WORKDIR /app

RUN useradd --create-home appuser

COPY --from=builder /install /usr/local
COPY app.py .

USER appuser

EXPOSE 5000

CMD ["gunicorn", "--bind", "0.0.0.0:5000", "--workers", "2", "app:app"]

What does this Dockerfile do?

The first stage (builder) installs your Python dependencies into a separate /install directory. The second stage starts fresh from the same slim base image.

  • COPY --from=builder /install /usr/local pulls only the installed packages from the build stage, leaving behind pip's cache and build artifacts.
  • useradd --create-home appuser creates a non-root user. Running containers as root is a security risk in production.
  • The CMD runs Gunicorn with 2 worker processes, which is a production-ready WSGI server instead of Flask's development server.
  • Save Dockerfile.
  • Confirm you now have four files in your Explorer sidebar: app.py, Dockerfile, requirements.txt, and test_app.py.

File named Dockerfile.txt instead of Dockerfile?

Windows sometimes adds a hidden .txt extension. In VS Code, right-click the file in the sidebar and select Rename. Make sure the name is exactly Dockerfile with no extension.

My Dockerfile has the wrong name or extension. How do I fix it?

✔️ Awesome, I've got everything!

Great. Make sure you've saved the Dockerfile before moving on to the build step.

ⓧ I'd like to double check the full code

# Build stage
FROM python:3.12-slim AS builder

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt

# Production stage
FROM python:3.12-slim

WORKDIR /app

RUN useradd --create-home appuser

COPY --from=builder /install /usr/local
COPY app.py .

USER appuser

EXPOSE 5000

CMD ["gunicorn", "--bind", "0.0.0.0:5000", "--workers", "2", "app:app"]
Build and test the application locally

Before pushing anything to your pipeline, verify the container works on your machine. This catches issues early and saves pipeline compute minutes.

  • Open a terminal in VS Code by pressing Ctrl+` (backtick key, top-left of your keyboard).
  • Make sure Docker Desktop is running. You can confirm by checking for the Docker whale icon in your system tray.
  • Build your Docker image by running this command:
docker build -t nextwork-flask-app .

What does this command do?

  • docker build reads your Dockerfile and executes each instruction to produce an image.
  • -t nextwork-flask-app tags the image with a name so you can reference it easily.
  • The . at the end tells Docker to use the current directory as the build context.

The build takes 30-60 seconds as it downloads the Python base image and installs dependencies. You should see a Successfully tagged nextwork-flask-app:latest message at the end.

Seeing a build error?

  • If you see Cannot connect to the Docker daemon, Docker Desktop is not running. Open it and wait for it to finish starting.
  • If you see no such file or directory, make sure your terminal is inside the nextwork-flask-app folder. Run cd ~/Desktop/nextwork-flask-app to navigate there.

Why is my Docker build failing?

  • Run your container by executing this command:
docker run -d -p 5000:5000 --name flask-test nextwork-flask-app

What does this command do?

  • -d runs the container in the background (detached mode).
  • -p 5000:5000 maps port 5000 on your machine to port 5000 inside the container.
  • --name flask-test gives the container a name so you can stop it easily later.
  • Test the health endpoint by running:
curl http://localhost:5000/health

You should see this JSON response:

{"status":"healthy"}
  • Test the home endpoint by opening http://localhost:5000 in your browser.

You should see a JSON response containing your message and version 1.0.0.

Getting connection refused?

  • Run docker ps to check if the container is running. If it's not listed, it may have crashed on startup.
  • Run docker logs flask-test to see any error messages from inside the container.
  • If port 5000 is already in use, stop the conflicting process or change the host port: docker run -d -p 5001:5000 --name flask-test nextwork-flask-app and access http://localhost:5001/health instead.

Why is my Flask container not responding on localhost?

  • Stop and remove the test container now that you've confirmed everything works:
docker stop flask-test
docker rm flask-test

Your application is containerized and tested locally. Next up, you'll push this code to GitLab and configure the CI/CD variables that let your pipeline talk to AWS.

Set Up GitLab and Configure CI/CD Variables

Your Flask app is containerized and tested locally. Now you need a place to store your code that can also run your CI/CD pipeline automatically.

In this step, you'll push your code to GitLab and configure the AWS credentials your pipeline needs to deploy containers. This connects your code repository to your AWS infrastructure.

In this step, get ready to:
  • Create a GitLab project and push your local code to the main branch.
  • Configure CI/CD variables so your pipeline can authenticate with AWS.
  • Verify your repository structure on GitLab.
Create a GitLab project and push your code

GitLab.com hosts your code and provides the CI/CD runners that will execute your pipeline. You need to create a project there and push your local files to it.

  • Open your browser and go to https://gitlab.com.
  • Sign in to your GitLab account, or create a free account if you don't have one.
  • Click New project on your GitLab dashboard.
  • Select Create blank project.
  • Enter nextwork-flask-app as the project name.
  • Set the visibility level to Private.
  • Uncheck Initialize repository with a README (you'll push your existing code instead).
  • Click Create project.

GitLab requires a credit card for shared runners

GitLab.com requires a credit card on file to use their shared CI/CD runners on the free tier. This is to prevent crypto mining abuse. You won't be charged anything for normal usage.

If prompted, add your card under Settings > Usage Quotas. The free tier includes 400 compute minutes per month, which is more than enough for this project.

GitLab shows you instructions for pushing an existing repository. You'll use those commands now to connect your local project.

  • Open your terminal and make sure you're inside your project folder (the one containing app.py).
  • Initialize a Git repository and push your code to GitLab by running these commands:
git init
git add app.py requirements.txt test_app.py Dockerfile
git commit -m "Initial commit: Flask app with Dockerfile"
git remote add origin https://gitlab.com/[[GITLAB_USERNAME="your-gitlab-username"]]/nextwork-flask-app.git
git push -u origin main

What do these commands do?

  • git init creates a new Git repository in your current folder.
  • git add stages the four files you built in the previous step.
  • git remote add origin connects your local repo to the GitLab project you just created.
  • git push -u origin main uploads your code to GitLab's main branch.
  • After the push completes, refresh your GitLab project page in the browser. You should see your four files listed: app.py, requirements.txt, test_app.py, and Dockerfile.

Push failed or asked for authentication?

  • If Git asks for a username and password, enter your GitLab username and a Personal Access Token (not your account password). Create a token under GitLab Settings > Access Tokens with the write_repository scope.
  • If you see error: src refspec main does not match any, your default branch might be named master. Run git branch -M main to rename it, then try pushing again.

Help me push my code to GitLab

Configure CI/CD variables in GitLab

Your pipeline will need to authenticate with AWS to push images to ECR and deploy to ECS. GitLab CI/CD variables let you store these credentials securely so your pipeline jobs can access them without hardcoding secrets in your code.

  • In your GitLab project, click Settings in the left sidebar.
  • Click CI/CD.
  • Expand the Variables section.
  • Click Add variable.

You'll add four variables, one at a time. Use the credentials you saved from the gitlab-cicd-deployer IAM user you created in Step 1.

Add the first variable with these settings:

  • Set Key to AWS_ACCESS_KEY_ID.
  • Paste your IAM access key ID into the Value field.
  • Check both Protect variable and Mask variable.
  • Click Add variable.

Repeat the process for the remaining three variables:

  • AWS_SECRET_ACCESS_KEY - paste your IAM secret access key. Check both Protect variable and Mask variable.
  • AWS_DEFAULT_REGION - enter your AWS region (e.g. us-east-1). This should match the region where you created your ECR repository.
  • AWS_ACCOUNT_ID - enter your 12-digit AWS account ID. You can find this by clicking your account name in the top-right corner of the AWS Console.

Why mark variables as Protected and Masked?

Protected means the variable is only available to pipelines running on protected branches (like main). This prevents someone from creating a rogue branch that steals your credentials.

Masked means the value is hidden in pipeline logs. If a script accidentally prints the variable, GitLab replaces it with [MASKED] in the output.

  • After adding all four variables, confirm they appear in the Variables section. You should see AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_DEFAULT_REGION, and AWS_ACCOUNT_ID listed with their respective flags.

Variable won't save as masked?

GitLab requires masked variables to meet certain format requirements (no special characters that break log parsing). If your secret contains characters that GitLab won't accept for masking, try unchecking Mask variable for that specific variable. Keep Protect variable checked regardless.

Help me configure my GitLab CI/CD variables

Verify your repository structure on GitLab

Before you write the pipeline file, confirm that GitLab has all the files your pipeline will need to work with.

  • Click on your project name in the top-left breadcrumb to return to the repository view.
  • Confirm you see these four files in the repository root:
  • app.py - your Flask application.
  • requirements.txt - your Python dependencies.
  • test_app.py - your pytest test file.
  • Dockerfile - your multi-stage build configuration.
  • Click on app.py to open it. Verify the code matches what you wrote locally, including the /health endpoint.

Missing files in the repository?

  • Make sure you ran git add with all four filenames before committing. If a file is missing, add it locally, commit, and push again.
  • Check that you're looking at the main branch in the branch dropdown at the top of the file list.

Help me fix my GitLab repository

Your code is on GitLab and your AWS credentials are stored securely. Next, you'll write the .gitlab-ci.yml pipeline file that ties everything together.

Write the GitLab CI/CD Pipeline

Your GitLab project has all the AWS credentials it needs to interact with your cloud resources. Now it's time to write the instructions that tell GitLab exactly what to do with those credentials on every push.

Everything GitLab CI/CD does is driven by a single file called .gitlab-ci.yml. You'll define three stages in this file: test your code, build your Docker image, and deploy it to ECS Fargate.

In this step, get ready to:
  • Write the test and build stages that validate your code and push a Docker image to ECR.
  • Write the deploy stage that updates your ECS service with the freshly built image.
  • Push the pipeline to GitLab and verify the test and build stages pass.
Create the test and build stages

The .gitlab-ci.yml file lives in the root of your repository. GitLab automatically detects it and runs the pipeline you define inside it.

  • Create a new file called .gitlab-ci.yml in your project folder (the same folder that contains app.py and Dockerfile).
  • Add the pipeline stages and variables by pasting this code into the file:
stages:
  - test
  - build
  - deploy

variables:
  IMAGE_NAME: nextwork-flask-app
  ECR_REGISTRY: $AWS_ACCOUNT_ID.dkr.ecr.$AWS_DEFAULT_REGION.amazonaws.com
  IMAGE_TAG: $CI_COMMIT_SHORT_SHA
  ECS_CLUSTER: nextwork-cluster
  ECS_SERVICE: nextwork-flask-service
  ECS_TASK_FAMILY: nextwork-flask-task
  DOCKER_TLS_CERTDIR: ""

What does this code do?

The stages block defines the order your pipeline runs: test first, then build, then deploy. If any stage fails, the pipeline stops.

The variables block sets reusable values across all jobs. $CI_COMMIT_SHORT_SHA is a GitLab-provided value that gives you the first 8 characters of the commit hash. This tags each image with the exact code version that built it.

DOCKER_TLS_CERTDIR set to empty disables TLS for the Docker-in-Docker connection. This simplifies the setup on GitLab.com shared runners.

  • Save .gitlab-ci.yml.
  • Confirm the file exists by running:
ls

You should see .gitlab-ci.yml listed alongside app.py, Dockerfile, requirements.txt, and test_app.py.

Don't see .gitlab-ci.yml?

Files starting with a dot are hidden by default on macOS and Linux. Run ls -a to see all files including hidden ones.

Make sure you created the file in the correct folder. It should be in the same directory as app.py.

Help me find my .gitlab-ci.yml file

  • Open .gitlab-ci.yml and add the test job below the variables block:
test:
  stage: test
  image: python:3.12-slim
  script:
    - pip install -r requirements.txt
    - pytest test_app.py -v

What does this job do?

The test job spins up a fresh python:3.12-slim container, installs your dependencies, and runs pytest against your test file. If any test fails, the entire pipeline stops before building or deploying broken code.

You won't see output from this job yet. It runs when you push the file to GitLab later in this step.

  • Add the build job below the test job in .gitlab-ci.yml:
build:
  stage: build
  image: docker:27
  services:
    - docker:27-dind
  before_script:
    - apk add --no-cache aws-cli
    - aws ecr get-login-password --region $AWS_DEFAULT_REGION |
      docker login --username AWS --password-stdin $ECR_REGISTRY
  script:
    - docker build -t $ECR_REGISTRY/$IMAGE_NAME:$IMAGE_TAG -t $ECR_REGISTRY/$IMAGE_NAME:latest .
    - docker push $ECR_REGISTRY/$IMAGE_NAME:$IMAGE_TAG
    - docker push $ECR_REGISTRY/$IMAGE_NAME:latest
  rules:
    - if: $CI_COMMIT_BRANCH == "main"

What does this job do?

This job uses Docker-in-Docker (docker:27-dind service) to run Docker commands inside the GitLab runner. The runner itself is a container, so it needs a special "Docker inside Docker" setup to build images.

The before_script installs the AWS CLI and authenticates to your ECR registry. The script then builds the image with two tags: the commit SHA (for traceability) and latest (for convenience).

The rules block ensures this job only runs on pushes to the main branch. Feature branches will run tests but skip building and deploying.

Why Docker-in-Docker instead of Kaniko?

GitLab supports Kaniko for building images without Docker-in-Docker. However, Docker-in-Docker is more transferable. The docker build and docker push commands you learn here work the same way in any CI system, not just GitLab.

  • Save .gitlab-ci.yml.

Worried about YAML indentation?

YAML is strict about indentation. Each nested level uses exactly 2 spaces (not tabs). If your editor is inserting tabs, configure it to use spaces for .yml files.

The stage:, image:, and script: keys should all be indented 2 spaces under the job name. List items under script: should be indented 4 spaces total.

Help me fix my YAML indentation

Write the deploy stage

The deploy stage is where your pipeline actually puts the new image into production. It fetches the current ECS task definition, swaps in the new image tag, registers a new revision, and tells the service to update.

  • Add the deploy job below the build job in .gitlab-ci.yml:
deploy:
  stage: deploy
  image: registry.gitlab.com/gitlab-org/cloud-deploy/aws-base:latest
  script:
    - |
      TASK_DEFINITION=$(aws ecs describe-task-definition \
        --task-definition $ECS_TASK_FAMILY \
        --region $AWS_DEFAULT_REGION \
        --query 'taskDefinition' \
        --output json)
    - |
      NEW_TASK_DEF=$(echo $TASK_DEFINITION | sed -e "s|\"image\":\"[^\"]*\"|\"image\":\"$ECR_REGISTRY/$IMAGE_NAME:$IMAGE_TAG\"|g" | \
        jq 'del(.taskDefinitionArn, .revision, .status, .requiresAttributes, .compatibilities, .registeredAt, .registeredBy)')
    - |
      NEW_REVISION=$(aws ecs register-task-definition \
        --region $AWS_DEFAULT_REGION \
        --cli-input-json "$NEW_TASK_DEF" \
        --query 'taskDefinition.taskDefinitionArn' \
        --output text)
    - |
      aws ecs update-service \
        --cluster $ECS_CLUSTER \
        --service $ECS_SERVICE \
        --task-definition $NEW_REVISION \
        --region $AWS_DEFAULT_REGION
    - echo "Deployed $ECR_REGISTRY/$IMAGE_NAME:$IMAGE_TAG to ECS"
  rules:
    - if: $CI_COMMIT_BRANCH == "main"
  environment:
    name: production

What does this job do?

This job uses GitLab's aws-base image, which has the AWS CLI pre-installed. The script performs four steps in sequence:

  • Fetches the current task definition JSON from ECS.
  • Uses sed to swap the old image URI with the new one, then strips read-only AWS fields with jq.
  • Registers a new task definition revision with the updated image.
  • Updates the ECS service to use the new revision, triggering a rolling deployment.

The environment: production setting tells GitLab this job deploys to production, which enables environment tracking in the GitLab UI.

Why write commands manually instead of using GitLab's ECS template?

GitLab provides a pre-built AWS/Deploy-ECS.gitlab-ci.yml template that abstracts these steps. Writing the commands explicitly teaches you the ECS API surface directly. These same commands work in any CI system, making the knowledge transferable beyond GitLab.

  • Save .gitlab-ci.yml.

Confused by the sed and jq commands?

The sed command finds the "image":"..." pattern in the JSON and replaces the value with your new image URI. The jq command removes read-only fields that AWS adds to task definitions (like taskDefinitionArn and revision). You must strip these fields because AWS rejects them when you register a new revision.

Help me understand the deploy script

✔️ Awesome, I've got everything!

Double check you've saved your file. Your .gitlab-ci.yml should now have all three jobs: test, build, and deploy.

ⓧ I'd like to double check the full code

Here's the complete .gitlab-ci.yml file. Make sure your version matches this exactly:

stages:
  - test
  - build
  - deploy

variables:
  IMAGE_NAME: nextwork-flask-app
  ECR_REGISTRY: $AWS_ACCOUNT_ID.dkr.ecr.$AWS_DEFAULT_REGION.amazonaws.com
  IMAGE_TAG: $CI_COMMIT_SHORT_SHA
  ECS_CLUSTER: nextwork-cluster
  ECS_SERVICE: nextwork-flask-service
  ECS_TASK_FAMILY: nextwork-flask-task
  DOCKER_TLS_CERTDIR: ""

test:
  stage: test
  image: python:3.12-slim
  script:
    - pip install -r requirements.txt
    - pytest test_app.py -v

build:
  stage: build
  image: docker:27
  services:
    - docker:27-dind
  before_script:
    - apk add --no-cache aws-cli
    - aws ecr get-login-password --region $AWS_DEFAULT_REGION |
      docker login --username AWS --password-stdin $ECR_REGISTRY
  script:
    - docker build -t $ECR_REGISTRY/$IMAGE_NAME:$IMAGE_TAG -t $ECR_REGISTRY/$IMAGE_NAME:latest .
    - docker push $ECR_REGISTRY/$IMAGE_NAME:$IMAGE_TAG
    - docker push $ECR_REGISTRY/$IMAGE_NAME:latest
  rules:
    - if: $CI_COMMIT_BRANCH == "main"

deploy:
  stage: deploy
  image: registry.gitlab.com/gitlab-org/cloud-deploy/aws-base:latest
  script:
    - |
      TASK_DEFINITION=$(aws ecs describe-task-definition \
        --task-definition $ECS_TASK_FAMILY \
        --region $AWS_DEFAULT_REGION \
        --query 'taskDefinition' \
        --output json)
    - |
      NEW_TASK_DEF=$(echo $TASK_DEFINITION | sed -e "s|\"image\":\"[^\"]*\"|\"image\":\"$ECR_REGISTRY/$IMAGE_NAME:$IMAGE_TAG\"|g" | \
        jq 'del(.taskDefinitionArn, .revision, .status, .requiresAttributes, .compatibilities, .registeredAt, .registeredBy)')
    - |
      NEW_REVISION=$(aws ecs register-task-definition \
        --region $AWS_DEFAULT_REGION \
        --cli-input-json "$NEW_TASK_DEF" \
        --query 'taskDefinition.taskDefinitionArn' \
        --output text)
    - |
      aws ecs update-service \
        --cluster $ECS_CLUSTER \
        --service $ECS_SERVICE \
        --task-definition $NEW_REVISION \
        --region $AWS_DEFAULT_REGION
    - echo "Deployed $ECR_REGISTRY/$IMAGE_NAME:$IMAGE_TAG to ECS"
  rules:
    - if: $CI_COMMIT_BRANCH == "main"
  environment:
    name: production
Push the pipeline to GitLab

Your pipeline file is ready locally. Pushing it to GitLab triggers the pipeline automatically. The deploy stage will fail for now because you haven't created the ECS task definition and service yet. That's expected.

  • Stage, commit, and push your pipeline file by running these commands:
git add .gitlab-ci.yml
git commit -m "Add CI/CD pipeline"
git push origin main

What do these commands do?

git add stages the file for commit. git commit saves a snapshot with a message. git push origin main sends the commit to your GitLab remote, which triggers the pipeline.

  • Open your GitLab project in your browser.
  • Click Build in the left sidebar.
  • Click Pipelines.

You should see a new pipeline running with three stages shown as circles. The test stage should pass (green check). The build stage should also pass (green check). The deploy stage will fail (red X) because the ECS task definition does not exist yet.

Why does the deploy stage fail?

The deploy job tries to fetch a task definition called nextwork-flask-task from ECS. You haven't created that yet. You'll set it up in the next step, and then the full pipeline will work end to end.

  • Click on the pipeline to expand it.
  • Click on the test job to see its output. You should see both tests passing with 2 passed in the pytest results.
  • Click on the build job to confirm the Docker image was built and pushed to ECR successfully.

Test or build stage failing?

If the test stage fails, click into the job log and look for import errors. Make sure your requirements.txt was pushed to GitLab and contains flask and pytest.

If the build stage fails with an authentication error, double check your CI/CD variables in Settings > CI/CD > Variables. Confirm AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_DEFAULT_REGION, and AWS_ACCOUNT_ID are all set correctly with no extra spaces.

If you see no basic auth credentials, the ECR login failed. Check that your AWS_ACCOUNT_ID variable contains only digits (no dashes or spaces).

Help me debug my failing pipeline

GitLab requires a credit card for shared runners

GitLab.com requires a credit card on file to use shared runners on the free tier. This prevents abuse. If your pipeline is stuck in "pending" status, go to your account settings and add a payment method. You will not be charged within the free tier's 400 compute minutes per month.

Your pipeline is working. The test and build stages confirm that your code passes tests and your Docker image reaches ECR. Next up, you'll create the ECS task definition and service so the deploy stage has somewhere to send the container.

Create the ECS Task Definition and Service

Your pipeline can build and push Docker images to ECR. But the deploy stage needs something to deploy to. ECS Fargate needs two resources before it can run your container: a task definition and a service.

A task definition is the blueprint that tells Fargate how to run your container. It specifies the image, CPU, memory, ports, and IAM roles. A service uses that blueprint to launch and maintain a running container with a public IP.

In this step, get ready to:
  • Register an ECS task definition with your ECR image.
  • Create a security group and ECS service to run your container.
  • Verify your app is accessible via a public IP.
Register the ECS task definition

The task definition tells Fargate exactly what to run: which image to pull, how much CPU and memory to allocate, which port to expose, and which IAM role to use for pulling the image from ECR.

  • Open the AWS Management Console and navigate to Amazon Elastic Container Service.
  • In the left sidebar, click Task definitions.
  • Click Create new task definition and select Create new task definition from the dropdown.
  • For Task definition family, enter nextwork-flask-task.
  • Under Infrastructure requirements, confirm Launch type is set to AWS Fargate.
  • For Operating system/Architecture, select Linux/X86_64.
  • For CPU, select .25 vCPU.
  • For Memory, select .5 GB.
  • For Task execution role, select Create new role. This creates the ecsTaskExecutionRole that grants Fargate permission to pull your image from ECR.

What is the task execution role?

The task execution role is an IAM role that the ECS agent uses on your behalf. It needs permission to pull container images from ECR and send logs to CloudWatch. AWS provides a managed policy called AmazonECSTaskExecutionRolePolicy that covers both.

  • Scroll down to the Container - 1 section.
  • For Name, enter nextwork-flask-app.
  • For Image URI, enter your ECR repository URI followed by :latest. The format is:
[[AWS_ACCOUNT_ID="your AWS account ID"]].dkr.ecr.[[AWS_REGION="your AWS region e.g. us-east-1"]].amazonaws.com/nextwork-flask-app:latest

Find your ECR URI quickly

Open a new tab, go to the ECR console, click on your nextwork-flask-app repository, and copy the URI from the top of the page. Append :latest to the end.

  • Under Port mappings, set Container port to 5000.
  • Set Protocol to TCP.
  • Leave all other settings at their defaults.
  • Click Create at the bottom of the page.

You should see a success banner confirming your task definition nextwork-flask-task:1 was registered.

Task definition failed to create?

If you see a validation error, double-check that you selected AWS Fargate as the launch type. Also confirm the image URI matches your ECR repository exactly, including the region and account ID.

If the task execution role fails to create, go to IAM in a new tab and check if ecsTaskExecutionRole already exists. If it does, select Use an existing role instead.

Help me troubleshoot my ECS task definition creation error

Create a security group and ECS service

Your Fargate task needs a security group that allows incoming traffic on port 5000. The default VPC security group blocks all inbound traffic from the internet, so you need to create a custom one.

  • Open the EC2 console in a new tab.
  • In the left sidebar, scroll down and click Security Groups under the Network & Security section.
  • Click Create security group.
  • For Security group name, enter nextwork-flask-sg.
  • For Description, enter Allow inbound traffic on port 5000 for ECS Fargate task.
  • For VPC, select your default VPC.
  • Under Inbound rules, click Add rule.
  • Set Type to Custom TCP.
  • Set Port range to 5000.
  • Set Source to Anywhere-IPv4 (0.0.0.0/0).
  • Click Create security group.

Why create a custom security group?

The default VPC security group only allows inbound traffic from other resources in the same security group. Your Flask app listens on port 5000, and you want to access it from your browser over the public internet. This custom rule opens that specific port to all IPv4 addresses.

Now create the ECS service that will use your task definition and this security group.

  • Go back to the Amazon ECS console.
  • Click Clusters in the left sidebar, then click nextwork-cluster.
  • Click the Services tab, then click Create.
  • Under Compute configuration, confirm Launch type is selected and set to FARGATE.
  • Under Deployment configuration, for Family, select nextwork-flask-task.
  • For Service name, enter nextwork-flask-service.
  • For Desired tasks, enter 1.

What is a desired task count?

The desired task count tells ECS how many copies of your container to keep running. If a task crashes or stops, ECS automatically launches a new one to maintain this count. For learning purposes, 1 is all you need.

  • Expand the Networking section.
  • For VPC, select your default VPC.
  • For Subnets, select at least one public subnet. Any of the default subnets work.
  • Under Security group, remove the default security group by clicking the X next to it.
  • Select Use an existing security group and choose nextwork-flask-sg from the dropdown.
  • For Public IP, make sure it is set to Turned on. This assigns a public IP to your Fargate task so you can access it from your browser.
  • Leave all other settings at defaults and click Create.

ECS begins deploying your service. The task will transition from PROVISIONING to RUNNING within 1-2 minutes.

Task stuck in PENDING or STOPPED?

If your task moves to STOPPED instead of RUNNING, click on the task and check the Stopped reason field. Common causes:

  • "CannotPullContainerError" means the execution role cannot access ECR. Verify that your task definition has the ecsTaskExecutionRole attached.
  • "ResourceNotFoundException" means the image URI is wrong. Go back to ECR and confirm the repository has at least one image tagged latest.
  • If the task never starts, check that your subnet has an internet gateway route. Default VPC subnets have this by default.

Help me troubleshoot my ECS Fargate task that is not running

Verify the deployment

Once your task shows RUNNING status, you can access your Flask app from the public internet.

  • In the ECS console, click Clusters in the sidebar, then click nextwork-cluster.
  • Click the Tasks tab.
  • Click on the running task (its ID is a clickable link).
  • In the task details page, look for the Networking section.
  • Copy the Public IP address.
  • Open a new browser tab and go to:
http://[[TASK_PUBLIC_IP="your task's public IP address"]]:5000

You should see a JSON response containing your app's message and version number.

What should I see?

Your browser should display something like:

{"message":"Hello from NextWork! Deployed via GitLab CI/CD to ECS Fargate.","version":"1.0.0"}

This confirms your container is running on Fargate and accessible over the internet. The version field comes from the APP_VERSION environment variable, which defaults to 1.0.0 when not set.

  • Also try the health endpoint by navigating to:
http://[[TASK_PUBLIC_IP="your task's public IP address"]]:5000/health

You should see {"status":"healthy"} confirming the health check endpoint works.

Page not loading in the browser?

  • Make sure you're using http:// (not https://). Your container does not have TLS configured.
  • Confirm the security group nextwork-flask-sg has an inbound rule for TCP port 5000 from 0.0.0.0/0.
  • Verify the task status is RUNNING (not PROVISIONING or STOPPED). If it just started, wait another 30 seconds and try again.

Help me access my ECS Fargate task via the public IP

Your container is live on the internet, running on AWS Fargate with zero server management. Next up, you'll trigger the full pipeline end-to-end by pushing a code change and watching it deploy automatically.

Trigger the Full Pipeline and Verify Deployment

Your ECS service is running and accessible at a public IP. The pipeline is configured and ready to deploy on every push to main.

Now it's time to prove the full automation works end-to-end. You'll make a visible code change, push it, and watch your pipeline automatically test, build, and deploy the update to your live Fargate container.

In this step, get ready to:
  • Update your Flask app's response message and push the change to GitLab.
  • Watch all three pipeline stages (test, build, deploy) complete successfully.
  • Verify the updated message appears at your application's public IP.
Update your Flask app and push to main

To prove the pipeline works, you need a visible change in the application's output. Changing the response message on the / endpoint is the simplest way to confirm end-to-end deployment.

  • Open app.py in your code editor.
  • Find this line inside the home() function:
"message": "Hello from NextWork! Deployed via GitLab CI/CD to ECS Fargate.",
  • Change the message string to something visibly different. For example, add v2 or replace it with a completely new sentence that you'll recognize when you visit the public IP.
  • Save app.py.

Now commit and push this change to trigger the pipeline.

  • Stage, commit, and push your change by running these commands:
git add app.py
git commit -m "Update home message to trigger pipeline"
git push origin main

What does this do?

Pushing to the main branch is the trigger for your pipeline. Your .gitlab-ci.yml has rules: - if: $CI_COMMIT_BRANCH == "main" on the build and deploy jobs, so only pushes to main trigger the full pipeline.

Push rejected or authentication error?

If Git asks for credentials, use your GitLab username and a Personal Access Token (not your password). You can create one under User Settings > Access Tokens with the write_repository scope.

Help me push my code to GitLab

Watch the pipeline execute in GitLab

The moment you pushed, GitLab CI/CD picked up the commit and started running your pipeline. You can watch it in real time.

  • In your GitLab project, click Build in the left sidebar.
  • Click Pipelines.
  • You should see a new pipeline running (or recently completed) for your latest commit.
  • Click on the pipeline to see the three stages: test, build, and deploy.
  • Wait for all three stages to show green checkmarks. This typically takes 3-5 minutes.

What happens in each stage?

  • test installs your dependencies and runs pytest against your app. If any test fails, the pipeline stops here.
  • build uses Docker-in-Docker to build your image and push it to ECR with both a commit SHA tag and a latest tag.
  • deploy registers a new ECS task definition revision pointing to the freshly built image, then updates your service to use it.

Pipeline stage failed?

Click the failed job to see its logs. Common issues include:

  • Test stage fails: your code change may have broken the test assertions. Check that your home() function still returns a JSON object with a message key.
  • Build stage fails: check that your CI/CD variables (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_ACCOUNT_ID) are set correctly in Settings > CI/CD > Variables.
  • Deploy stage fails: verify your ECS service name (nextwork-flask-service) and cluster name (nextwork-cluster) match what you created in the AWS Console.

Help me debug my failed GitLab pipeline

Verify the live deployment

Once the deploy stage completes, ECS performs a rolling update. It launches a new task with the updated image and stops the old one. This means your task's public IP may change.

  • Open the AWS Console and navigate to Amazon Elastic Container Service.
  • Click on your nextwork-cluster cluster.
  • Click the Tasks tab.
  • Select the running task (it should show RUNNING status).
  • Click the Networking tab to find the task's public IP address.
  • Open a new browser tab and navigate to http://<public-ip>:5000 using the IP you just found.

You should see your updated message in the JSON response. This confirms that your code change traveled from your local machine, through GitLab's pipeline, into a new Docker image on ECR, and out to a live Fargate container.

How long does the rolling update take?

After the deploy stage finishes, ECS needs 1-3 minutes to pull the new image, start the new task, and drain the old one. If you still see the old message, wait a minute and refresh.

You may also see a PROVISIONING or PENDING task while the new container starts up. Wait until it shows RUNNING before checking the public IP.

Still seeing the old message?

  • Check that you're using the NEW task's public IP. The rolling update launches a new task with a different IP than the previous one.
  • Make sure the new task shows RUNNING status, not PROVISIONING. Give it another minute if it's still starting.
  • Confirm the deploy stage in GitLab completed successfully. If it failed silently, the old task definition is still in use.

Help me verify my ECS deployment

You've just proven a full CI/CD loop. Every future push to main will automatically test your code, package it into a Docker image, and deploy it live. Next up, you'll tackle a secret mission that adds rollback capability to your pipeline.

Secret mission

Add a Manual Rollback Job

Your pipeline deploys forward automatically. But what happens when a deployment goes wrong? Add a one-click rollback job that redeploys the previous image version, giving you a critical production safety net.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project uses AWS resources that cost approximately $0.01/hour while running, covered by free tier credits on new accounts.

Cost warning

Your ECS Fargate task costs approximately $0.01/hour (about $7/month) after free tier credits expire. If you are not planning to use this project again soon, choose Pause or Delete to avoid unexpected charges.

Resources you used:

  • ECS Fargate cluster (nextwork-cluster) and service (nextwork-flask-service).
  • ECR repository (nextwork-flask-app).
  • ECS task definition (nextwork-flask-task).
  • IAM user (gitlab-cicd-deployer).
  • Custom security group allowing inbound traffic on port 5000.
  • GitLab.com project (free, no ongoing cost).
  • Local project files and Docker images.

Keep everything running

No action needed. Choose this if you are still actively building or plan to experiment with the pipeline in the next few days.

  • Your ECS Fargate task continues running and costs approximately $0.01/hour.
  • ECR storage remains within the 500 MB free tier for the first 12 months on new accounts.
  • GitLab.com free tier gives you 400 compute minutes per month for pipelines.

Pause - I'll come back to this later

Stop the running task to eliminate Fargate charges while keeping everything ready to restart.

  • In the AWS Console, navigate to Amazon ECS and select your nextwork-cluster.
  • Select the nextwork-flask-service service.
  • Click Update and set Desired tasks to 0.
  • Click Update to confirm. No Fargate charges accrue while no tasks are running.
  • Your ECR images, task definition, and cluster remain intact at negligible cost.
  • To resume later, update the desired count back to 1 and the service will launch a new task.

Delete - I don't want to use this again

Remove all AWS resources created during this project. Your total cost going forward will be $0.

  • In the ECS Console, select nextwork-flask-service. Update the desired count to 0, then delete the service.
  • Delete the nextwork-cluster cluster.
  • In the ECR Console, delete the nextwork-flask-app repository. This removes all stored images.
  • In the IAM Console, delete the gitlab-cicd-deployer user.
  • In the EC2 Console under Security Groups, delete the custom security group you created for port 5000 access.
  • Optionally, delete the GitLab project under Settings > General > Advanced > Delete project.
  • Delete your local project folder and run docker system prune to remove unused Docker images.

That's a Wrap!

That's a Wrap!

Nice work! You just built a production-grade CI/CD pipeline that takes a containerized Flask application from code push to live deployment on AWS ECS Fargate.

You've learned how to:

  • Containerized a Python Flask app with a multi-stage Dockerfile and pushed it to AWS ECR with commit SHA tagging for version traceability.
  • Deployed a container to AWS ECS Fargate with a public IP and zero server management.
  • Automated the entire test-build-deploy flow with a GitLab CI/CD pipeline that triggers on every push to main.
  • Secret Mission: Added a manual rollback job to redeploy previous image versions in one click.

Ready to quiz yourself?