Deploy a FastAPI App to ECS Fargate

Containerize a FastAPI app and deploy it to AWS ECS Fargate via Amazon ECR.

Introduction

30 Second Summary

Every app you build eventually needs to run somewhere other than your laptop. In the real world, that means packaging your code into a container and shipping it to the cloud.

In this project, you will build a task management API with FastAPI, containerize it with Docker using production best practices, and deploy it to AWS ECS Fargate so it's accessible to anyone on the internet.

What You'll Build

A containerized FastAPI REST API deployed to AWS ECS Fargate, demonstrating the full local-development-to-cloud pipeline that backend engineers use in production.

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

  • A production-optimized Dockerfile using multi-stage builds, a non-root user, and health checks.
  • A Docker Compose setup running your FastAPI app alongside PostgreSQL for local development.
  • A live, publicly accessible API running on AWS ECS Fargate via Amazon ECR.
  • Secret Mission: Add a Redis caching layer to your Docker Compose setup and implement response caching on one of your endpoints.

Are there any prerequisites?

You'll need basic Python knowledge and familiarity with FastAPI (creating routes and running a dev server). You'll also need an AWS account (free tier works).

No prior Docker or AWS ECS experience is required. This project teaches both from scratch.

Before We Start

Before We Start...

Install and Configure the AWS CLI

To deploy your containerized app to AWS, you need a way to talk to AWS services from your terminal.

The AWS CLI (Command Line Interface) is the tool that makes this possible. In this step, you will install it and connect it to your AWS account so that later steps can push images and launch services.

In this step, get ready to:
  • Install AWS CLI v2 on your machine.
  • Verify the installation with a version check.
  • Configure your AWS credentials so the CLI can authenticate.
Install AWS CLI v2

The AWS CLI v2 lets you create cloud resources, push container images, and manage services directly from your terminal.

  • Open your terminal. Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal (macOS/Linux) or cmd (Windows) and press Enter to open it.
  • Check if you already have the AWS CLI installed by running this command:
aws --version

Look at the output and pick the tab that matches what you see.

✔️ I see version 2.x

Your output looks something like this:

aws-cli/2.27.41 Python/3.11.6 ...

The AWS CLI v2 is already installed. You are good to skip ahead to the configure step below.

ⓧ I see version 1.x

Your output shows something like aws-cli/1.x.x. You have version 1 installed, which is outdated. You need to upgrade to version 2.

macOS

  • Download and install AWS CLI v2 by running these commands:
curl "https://awscli.amazonaws.com/AWSCLIV2.pkg" -o "AWSCLIV2.pkg"
sudo installer -pkg AWSCLIV2.pkg -target /
What do these commands do?
  • curl downloads the official AWS CLI installer package.
  • sudo installer -pkg runs the macOS package installer, which replaces your v1 installation with v2.

Windows

  • Download and run the AWS CLI v2 MSI installer from this URL:
msiexec.exe /i https://awscli.amazonaws.com/AWSCLIV2.msi

Follow the on-screen prompts to complete the installation. The v2 installer replaces your existing v1 installation.

Linux

  • Download and install AWS CLI v2 by running these commands:
curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip"
unzip awscliv2.zip
sudo ./aws/install --bin-dir /usr/local/bin --install-dir /usr/local/aws-cli --update
What do these commands do?
  • curl downloads the installer zip file from AWS.
  • unzip extracts the installer.
  • The --update flag tells the installer to replace your existing v1 with v2.
  • After installing, close and reopen your terminal.
  • Verify the upgrade by running:
aws --version

You should now see aws-cli/2.x.x in the output.

ⓧ Command not found

The AWS CLI is not installed on your machine yet. Follow the instructions for your operating system below.

macOS

  • Download and install AWS CLI v2 by running these commands:
curl "https://awscli.amazonaws.com/AWSCLIV2.pkg" -o "AWSCLIV2.pkg"
sudo installer -pkg AWSCLIV2.pkg -target /
What do these commands do?
  • curl downloads the official installer package from AWS.
  • sudo installer -pkg runs the macOS package installer system-wide. You will be prompted for your computer password.

Windows

  • Download the AWS CLI v2 MSI installer by visiting this URL in your browser:

https://awscli.amazonaws.com/AWSCLIV2.msi

  • Run the downloaded .msi file and follow the on-screen prompts to complete the installation.

Linux

  • Download and install AWS CLI v2 by running these commands:
curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip"
unzip awscliv2.zip
sudo ./aws/install
What do these commands do?
  • curl downloads the installer zip from AWS.
  • unzip extracts it into an aws directory.
  • sudo ./aws/install runs the install script with admin permissions.
  • After installing, close and reopen your terminal.
  • Verify the installation by running:
aws --version

You should see aws-cli/2.x.x followed by your Python version and OS details.

Still seeing command not found after installing?

  • Make sure you fully closed and reopened your terminal after installing. The PATH update only takes effect in new terminal sessions.
  • On macOS or Linux, try running /usr/local/bin/aws --version to confirm the binary is there.
  • On Windows, try closing and reopening the Command Prompt as Administrator.

Help me troubleshoot why aws --version is not working after installing AWS CLI v2.

Configure your AWS credentials

Now that the CLI is installed, you need to connect it to your AWS account. This lets the CLI authenticate when creating resources like container registries and ECS clusters.

Where do I find my Access Key ID and Secret Access Key?

You create access keys in the IAM section of the AWS Console. Go to IAM > Users > select your user > Security credentials tab > Create access key.

Copy both the Access Key ID and Secret Access Key when they appear. You will not be able to see the secret key again after closing the dialog.

  • Run the configure command to set up your credentials:
aws configure

The command will prompt you for four values. Enter them one at a time as each prompt appears:

  • AWS Access Key ID: paste your Access Key ID.
  • AWS Secret Access Key: paste your Secret Access Key.
  • Default region name: enter us-east-1 (or your preferred region).
  • Default output format: enter json.

What does aws configure do?

This command saves your credentials and preferences to a file at ~/.aws/credentials (macOS/Linux) or C:\Users\YourName\.aws\credentials (Windows). Every future aws command reads from this file to authenticate.

  • Verify your credentials are working by running:
aws sts get-caller-identity

You should see a JSON response containing your AWS account ID, user ARN, and user ID. This confirms the CLI can authenticate successfully.

Seeing an authentication error?

  • Double-check that you pasted the correct Access Key ID and Secret Access Key without any extra spaces.
  • Verify the access key is active in the IAM console. Newly created keys are active by default, but an administrator may have disabled yours.
  • Run aws configure again to re-enter your credentials from scratch.

Help me fix my AWS CLI authentication error after running aws sts get-caller-identity.

Your AWS CLI is installed and authenticated. Next up, you will build the FastAPI API that will eventually run inside a container on AWS.

Build the FastAPI API

Your AWS CLI is configured and ready to push containers to the cloud later. Before you can containerize anything, you need something to containerize.

In this step, you will build a task management REST API using FastAPI. The API will have a health check endpoint and full CRUD (Create, Read, Update, Delete) operations for tasks.

In this step, get ready to:
  • Create a project structure with pinned Python dependencies.
  • Write a FastAPI application with a health check and CRUD endpoints.
  • Test the API locally to confirm it works before containerizing.
Create the project structure and install dependencies

Every Python project needs a clear folder structure and a list of exact package versions. Pinning versions ensures anyone running your code gets the same behaviour you tested with.

  • In the terminal, make sure you are inside your project folder from Step 1.
  • Create the app/ directory and an empty __init__.py file by running these commands:
mkdir app
touch app/__init__.py

Why an empty __init__.py?

The __init__.py file tells Python that the app/ folder is a package. Without it, Python cannot import modules from inside that directory.

  • Confirm the files exist by running:
ls app

You should see __init__.py in the output.

  • Create requirements.txt in your project folder by running:
touch requirements.txt
  • Open requirements.txt in your code editor and paste the following:
fastapi==0.136.1
uvicorn[standard]==0.46.0
pydantic==2.11.3

What do these packages do?

fastapi is the web framework for your API. uvicorn is the ASGI server that actually runs your FastAPI app. pydantic handles data validation and serialisation for request and response models.

The == pins each package to an exact version so your build is reproducible.

  • Save requirements.txt.
  • Create a Python virtual environment by running:
python3 -m venv .venv
  • Activate the virtual environment:

macOS

source .venv/bin/activate

Windows

.venv\Scripts\activate

Linux

source .venv/bin/activate

You should see (.venv) appear at the start of your terminal prompt.

  • Install the project dependencies by running:
pip install -r requirements.txt

You should see output ending with Successfully installed followed by the three package names and their versions.

Seeing a permission error or pip not found?

Make sure you activated the virtual environment first. Your prompt should show (.venv) before anything else. If it does not, re-run the activation command for your operating system above.

help me fix pip install errors in my virtual environment

Write the FastAPI application

Now you will build the actual API. The application uses in-memory storage (a Python dictionary) so you can focus on the API and Docker skills without database setup.

  • Create app/main.py by running:
touch app/main.py
  • Open app/main.py in your code editor and paste the following setup code:
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from datetime import datetime

app = FastAPI(title="Task Manager API")

# In-memory storage (production would use a database)
tasks: dict[int, dict] = {}
task_counter: int = 0


class TaskCreate(BaseModel):
    title: str
    description: str = ""


class TaskResponse(BaseModel):
    id: int
    title: str
    description: str
    completed: bool
    created_at: str

What does this code do?

app = FastAPI(title="Task Manager API") creates the FastAPI application instance that all your endpoints will attach to.

tasks is a dictionary that stores task data in memory. task_counter tracks the next available ID. The two Pydantic models (TaskCreate and TaskResponse) define the shape of incoming requests and outgoing responses.

  • Below the models, add the health check and collection endpoints:
@app.get("/health")
def health_check():
    return {"status": "healthy", "timestamp": datetime.now().isoformat()}


@app.get("/tasks", response_model=list[TaskResponse])
def list_tasks():
    return list(tasks.values())


@app.post("/tasks", response_model=TaskResponse, status_code=201)
def create_task(task: TaskCreate):
    global task_counter
    task_counter += 1
    new_task = {
        "id": task_counter,
        "title": task.title,
        "description": task.description,
        "completed": False,
        "created_at": datetime.now().isoformat(),
    }
    tasks[task_counter] = new_task
    return new_task

What does this code do?

/health returns a simple JSON response confirming the API is alive. Cloud platforms use this endpoint to decide whether your container is healthy.

GET /tasks returns all tasks. POST /tasks creates a new task by incrementing the counter, building a dictionary, and storing it in the tasks dict.

  • Below the create_task function, add the single-item endpoints:
@app.get("/tasks/{task_id}", response_model=TaskResponse)
def get_task(task_id: int):
    if task_id not in tasks:
        raise HTTPException(status_code=404, detail="Task not found")
    return tasks[task_id]


@app.patch("/tasks/{task_id}", response_model=TaskResponse)
def update_task(task_id: int, completed: bool):
    if task_id not in tasks:
        raise HTTPException(status_code=404, detail="Task not found")
    tasks[task_id]["completed"] = completed
    return tasks[task_id]


@app.delete("/tasks/{task_id}", status_code=204)
def delete_task(task_id: int):
    if task_id not in tasks:
        raise HTTPException(status_code=404, detail="Task not found")
    del tasks[task_id]

What does this code do?

These three endpoints operate on a single task by its ID. Each one checks whether the task exists first and returns a 404 error if it does not.

GET retrieves one task. PATCH toggles the completed flag. DELETE removes the task entirely and returns 204 No Content.

  • Save app/main.py.

✔️ Awesome, I've got everything!

Great. Double check you have saved app/main.py before moving on.

ⓧ I'd like to double check the full code

Here is the complete app/main.py file:

from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from datetime import datetime

app = FastAPI(title="Task Manager API")

# In-memory storage (production would use a database)
tasks: dict[int, dict] = {}
task_counter: int = 0


class TaskCreate(BaseModel):
    title: str
    description: str = ""


class TaskResponse(BaseModel):
    id: int
    title: str
    description: str
    completed: bool
    created_at: str


@app.get("/health")
def health_check():
    return {"status": "healthy", "timestamp": datetime.now().isoformat()}


@app.get("/tasks", response_model=list[TaskResponse])
def list_tasks():
    return list(tasks.values())


@app.post("/tasks", response_model=TaskResponse, status_code=201)
def create_task(task: TaskCreate):
    global task_counter
    task_counter += 1
    new_task = {
        "id": task_counter,
        "title": task.title,
        "description": task.description,
        "completed": False,
        "created_at": datetime.now().isoformat(),
    }
    tasks[task_counter] = new_task
    return new_task


@app.get("/tasks/{task_id}", response_model=TaskResponse)
def get_task(task_id: int):
    if task_id not in tasks:
        raise HTTPException(status_code=404, detail="Task not found")
    return tasks[task_id]


@app.patch("/tasks/{task_id}", response_model=TaskResponse)
def update_task(task_id: int, completed: bool):
    if task_id not in tasks:
        raise HTTPException(status_code=404, detail="Task not found")
    tasks[task_id]["completed"] = completed
    return tasks[task_id]


@app.delete("/tasks/{task_id}", status_code=204)
def delete_task(task_id: int):
    if task_id not in tasks:
        raise HTTPException(status_code=404, detail="Task not found")
    del tasks[task_id]
Test the API locally

Before containerizing the app, you need to confirm it runs correctly on its own. This saves debugging time later because you will know any future issues come from Docker, not from your Python code.

  • Start the development server by running:
uvicorn app.main:app --reload

What does this command do?

uvicorn is the ASGI server that hosts your FastAPI app. app.main:app tells uvicorn to look inside the app/ package, in the main.py module, and use the object called app.

The --reload flag restarts the server automatically when you save changes. This is for development only.

You should see output including Uvicorn running on http://127.0.0.1:8000.

  • Open a new terminal tab (keep uvicorn running in the first one).
  • Test the health endpoint by running:
curl http://localhost:8000/health

You should see a JSON response like:

{"status":"healthy","timestamp":"2026-05-20T10:00:00.000000"}
  • Test creating a task by running:
curl -X POST http://localhost:8000/tasks -H "Content-Type: application/json" -d '{"title": "Learn Docker", "description": "Containerize my FastAPI app"}'

You should see a JSON response with "id": 1 and the task details you just sent.

  • Verify the task was stored by running:
curl http://localhost:8000/tasks

You should see a JSON array containing the task you just created.

Seeing connection refused or no output?

Make sure the uvicorn server is still running in your other terminal tab. If you accidentally closed it, run uvicorn app.main:app --reload again from your project folder.

If you see a ModuleNotFoundError, make sure your virtual environment is activated (you should see (.venv) in your prompt).

help me debug why my FastAPI server is not responding to requests

What is a stateless service?

Notice that your API stores no session data between requests. Any instance of this app can handle any request independently.

This is the foundation of horizontal scaling. If traffic doubles, you spin up a second identical container. Stateless design is a core principle of the 12-Factor App methodology, and it is why containerized deployments work so well.

  • Return to the terminal running uvicorn and press Ctrl+C to stop the server.

Your API is tested and confirmed working. Next up, you will wrap it in a production-ready Docker image using a multi-stage build.

Write a Production Dockerfile

Your FastAPI API runs perfectly on your machine. But what about deploying it on someone else's machine, or a cloud server?

This is the core problem Docker solves. A Dockerfile packages your application, its dependencies, and its runtime environment into a single portable image. In this step, you'll write a production-grade Dockerfile using a multi-stage build pattern that keeps your final image small and secure.

In this step, get ready to:
  • Write a multi-stage Dockerfile that separates build tools from runtime.
  • Add production best practices including a non-root user and health check.
  • Build and run your container locally to verify it works.
Write the builder stage of your Dockerfile

A multi-stage build splits your Dockerfile into two parts. The first stage (builder) installs all the heavy build tools and dependencies. The second stage (runtime) copies only what's needed to run the app.

Why use multi-stage builds?

A naive Dockerfile that installs everything in one stage includes compilers, header files, and package caches in the final image. Multi-stage builds discard all of that, resulting in images that are roughly 60% smaller.

Smaller images mean faster deployments and a smaller attack surface in production.

  • Create a new file called Dockerfile in the root of your task-manager project folder (the same folder that contains requirements.txt).
  • Paste the following builder stage into your Dockerfile:
# Stage 1: Build
FROM python:3.12-slim AS builder

WORKDIR /app

RUN apt-get update && apt-get install -y --no-install-recommends \
    build-essential \
    && rm -rf /var/lib/apt/lists/*

RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"

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

What does this code do?

  • FROM python:3.12-slim AS builder starts from a minimal Python image and names this stage builder.
  • RUN apt-get... installs build tools (like a C compiler) needed to compile some Python packages. The rm -rf at the end removes cached package lists to keep the layer small.
  • RUN python -m venv /opt/venv creates a virtual environment at a known path. This makes it easy to copy the entire environment to the next stage.
  • COPY requirements.txt . and RUN pip install install your pinned dependencies into the virtual environment.

You won't see a change yet. The Dockerfile isn't complete until you add the runtime stage below.

Add the runtime stage with production best practices

The runtime stage starts from a fresh base image. It copies only the virtual environment from the builder stage and your application code. No compilers, no build tools, no bloat.

  • In your Dockerfile, add the following runtime stage directly below the builder stage you just wrote:
# Stage 2: Runtime
FROM python:3.12-slim AS runtime

RUN groupadd --gid 1000 appuser \
    && useradd --uid 1000 --gid appuser --shell /bin/bash --create-home appuser

WORKDIR /app

COPY --from=builder /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH" \
    PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1

COPY --chown=appuser:appuser ./app ./app

USER appuser

EXPOSE 8000

HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
    CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"

CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

What does this code do?

  • RUN groupadd... && useradd... creates a non-root user called appuser. Running as root inside a container is a security risk because a container escape would give the attacker root access to the host.
  • COPY --from=builder /opt/venv /opt/venv pulls the virtual environment (with all installed packages) from the builder stage into the runtime stage. No build tools come along.
  • PYTHONDONTWRITEBYTECODE=1 prevents Python from writing .pyc files. PYTHONUNBUFFERED=1 ensures logs appear immediately in docker logs without buffering.
  • HEALTHCHECK tells Docker (and later ECS) how to check if the container is alive. It hits your /health endpoint every 30 seconds.
  • CMD starts uvicorn, binding to all interfaces (0.0.0.0) so traffic from outside the container can reach the app.

Your app code runs as a non-root user with no access to build tools. This is the same security posture production containers use at companies like Stripe and Datadog.

  • Now create a .dockerignore file in the same folder as your Dockerfile. This tells Docker which files to exclude from the build context:
__pycache__
*.pyc
.git
.gitignore
.env
venv
.venv
*.md

Why use a .dockerignore?

Without .dockerignore, Docker sends everything in your project folder to the Docker daemon during a build. Excluding caches, virtual environments, and secrets keeps your build fast and your image clean.

  • Save both Dockerfile and .dockerignore.
  • Confirm both files exist by running:
ls Dockerfile .dockerignore

You should see both filenames printed in the output.

Don't see both files?

  • Make sure you're in the root project folder (the same folder that contains requirements.txt).
  • The .dockerignore file starts with a dot, which makes it hidden on macOS/Linux. Use ls -a to show hidden files.
  • Make sure your Dockerfile has no file extension. It should be Dockerfile, not Dockerfile.txt.

Having trouble?

✔️ Awesome, I've got everything!

Great. Make sure both files are saved before moving on.

ⓧ I'd like to double check the full code

# Stage 1: Build
FROM python:3.12-slim AS builder

WORKDIR /app

RUN apt-get update && apt-get install -y --no-install-recommends \
    build-essential \
    && rm -rf /var/lib/apt/lists/*

RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"

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

# Stage 2: Runtime
FROM python:3.12-slim AS runtime

RUN groupadd --gid 1000 appuser \
    && useradd --uid 1000 --gid appuser --shell /bin/bash --create-home appuser

WORKDIR /app

COPY --from=builder /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH" \
    PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1

COPY --chown=appuser:appuser ./app ./app

USER appuser

EXPOSE 8000

HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
    CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"

CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
__pycache__
*.pyc
.git
.gitignore
.env
venv
.venv
*.md
Build and run your Docker container

Your Dockerfile is ready. Time to turn it into a runnable container image and verify the app works inside it.

  • Build your Docker image by running this command:
docker build -t task-manager .

What does this command do?

  • docker build reads your Dockerfile and executes each instruction to produce an image.
  • -t task-manager tags (names) the resulting image so you can reference it easily.
  • . tells Docker to use the current directory as the build context (the files Docker can access during the build).

The build takes 30-60 seconds the first time. You'll see each stage execute in sequence.

Seeing a build error?

  • Make sure Docker Desktop is running. You should see the Docker whale icon in your system tray or menu bar.
  • Check that your Dockerfile has no file extension and is in the same folder as requirements.txt.
  • If you see a permissions error on Linux, you may need to prefix with sudo.

Help me debug my Docker build error.

  • Once the build succeeds, run your container:
docker run -p 8000:8000 task-manager

What does this command do?

  • docker run creates and starts a new container from the task-manager image.
  • -p 8000:8000 maps port 8000 on your machine to port 8000 inside the container. Without this, the container's port is invisible to your browser.
  • Open your browser and navigate to http://localhost:8000/health.

You should see a JSON response like this:

{"status": "healthy", "timestamp": "2026-05-20T12:00:00.000000"}

Your API is now running inside a Docker container with production-grade security and health monitoring.

Not seeing the health response?

  • Check your terminal for error messages. A common issue is a typo in the CMD line of your Dockerfile (the app path must be app.main:app).
  • Make sure port 8000 is not already in use. If you still have uvicorn running from the previous step, stop it first with Ctrl+C.
  • Verify the app/ folder is in the same directory as your Dockerfile. The COPY ./app ./app instruction copies from relative to the build context.

Help me figure out why my container isn't responding.

  • When you're done testing, press Ctrl+C in the terminal to stop the container.

Why is immutability important?

Your Docker image is immutable. The same image runs in development, staging, and production. Configuration (database URLs, secrets) is injected via environment variables, never baked into the image.

This follows 12-Factor App principle #3 (Config) and #10 (Dev/prod parity). In interviews, this is the answer to "how do you deploy the same code to different environments?"

Your API is now containerized and portable. Next up, you'll orchestrate multiple containers together using Docker Compose.

Set Up Docker Compose for Local Development

In the previous step, you built and ran your FastAPI container as a standalone service. That works for testing one container, but production apps rarely run alone.

Real backend systems need multiple services working together. Your API needs a database, and later it might need a cache or a message queue. Docker Compose lets you define and run all of these services from a single YAML file, so one command brings your entire stack online.

In this step, get ready to:
  • Create a docker-compose.yml that defines your API and a PostgreSQL database as connected services.
  • Configure health checks, dependency ordering, and persistent storage.
  • Bring the full stack online with a single command and verify everything works together.
Create the docker-compose.yml with your API service

The docker-compose.yml file declares every service your application needs and how they connect. You will start with the api service definition.

  • Create a new file called docker-compose.yml in your project's root folder (the same folder that contains your Dockerfile).
  • Paste the following code into docker-compose.yml:
services:
  api:
    build:
      context: .
      dockerfile: Dockerfile
    ports:
      - "8000:8000"
    environment:
      - DATABASE_URL=postgresql://taskuser:taskpass@db:5432/taskdb
    depends_on:
      db:
        condition: service_healthy
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
      interval: 30s
      timeout: 10s
      retries: 3

What does the api service define?

  • build tells Compose to build the image from your local Dockerfile instead of pulling a pre-built image.
  • ports maps port 8000 on your machine to port 8000 inside the container, just like docker run -p did earlier.
  • environment injects the database connection string. The hostname db refers to the database service you will define next.
  • depends_on with condition: service_healthy ensures Compose waits for the database to be fully ready before starting the API.
  • healthcheck defines how Compose monitors whether the API container is alive, using the same /health endpoint you built in Step 2.

You will not see a change yet. The next chunk adds the database service that the API depends on.

Add the database service and volume

Your API needs a PostgreSQL database to connect to. In Docker Compose, you can add a database as a service running alongside your API, with a named volume to persist data between restarts.

  • In docker-compose.yml, add the following below the api service (still inside the services: block) and a top-level volumes: section at the very bottom:
  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: taskuser
      POSTGRES_PASSWORD: taskpass
      POSTGRES_DB: taskdb
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U taskuser -d taskdb"]
      interval: 10s
      timeout: 5s
      retries: 5

volumes:
  pgdata:

What does the db service define?

  • image: postgres:16-alpine pulls the official PostgreSQL 16 image (Alpine variant for smaller size).
  • The environment variables tell PostgreSQL to create a user, password, and database on first startup.
  • volumes mounts a named volume (pgdata) so your database data survives container restarts.
  • healthcheck uses pg_isready to check whether PostgreSQL is accepting connections. This is what the API's depends_on: service_healthy condition waits for.

Why health checks matter for service startup

Without a health check, Docker Compose considers a container "ready" as soon as it starts running. But a database needs several seconds to initialize before it can accept connections.

The healthcheck in Docker Compose is the same pattern cloud platforms use. ECS, Kubernetes, and load balancers all poll a health endpoint to determine if your service is alive. If it fails, the orchestrator replaces the container automatically. This is how production systems achieve self-healing without human intervention.

  • Save docker-compose.yml.

YAML indentation errors?

YAML is sensitive to spacing. The db: key must be indented at the same level as api: (two spaces). The volumes: key at the bottom must be at the root level (zero indentation), aligned with services:.

help me fix YAML indentation errors in my docker-compose.yml

✔️ Awesome, I've got everything!

Great. Double check you have saved docker-compose.yml before moving on.

ⓧ I'd like to double check the full code

Here is the complete docker-compose.yml file. Make sure yours matches exactly:

services:
  api:
    build:
      context: .
      dockerfile: Dockerfile
    ports:
      - "8000:8000"
    environment:
      - DATABASE_URL=postgresql://taskuser:taskpass@db:5432/taskdb
    depends_on:
      db:
        condition: service_healthy
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
      interval: 30s
      timeout: 10s
      retries: 3

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: taskuser
      POSTGRES_PASSWORD: taskpass
      POSTGRES_DB: taskdb
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U taskuser -d taskdb"]
      interval: 10s
      timeout: 5s
      retries: 5

volumes:
  pgdata:
Run the full stack with Docker Compose

With your Compose file ready, one command will build your API image, pull the PostgreSQL image, create a network, and start both containers in the correct order.

  • Make sure you are in your project folder (the one containing docker-compose.yml).
  • Start the full stack by running:
docker compose up --build

What does this command do?

The --build flag tells Compose to rebuild the API image from your Dockerfile before starting. Without it, Compose might reuse a stale cached image.

Compose will pull the postgres:16-alpine image if you do not already have it. It will then start db first, wait for its health check to pass, and only then start api.

You should see logs from both services streaming into your terminal. Look for lines showing the database is ready and the API is accepting connections on port 8000.

  • Open a new terminal tab (leave the Compose logs running) and verify the API is responding by running:
curl http://localhost:8000/health

You should see a JSON response like {"status":"healthy","timestamp":"..."}.

  • Verify both containers are healthy by running:
docker compose ps

You should see both api and db listed with a status showing they are running and healthy.

Seeing "unhealthy" or connection errors?

  • If the db service shows "unhealthy", wait 30 seconds and run docker compose ps again. PostgreSQL needs time to initialize on first run.
  • If the api service fails to start, check that Docker Desktop is running and that port 8000 is not already in use by a previous docker run container. Stop any previous containers with docker stop $(docker ps -q).
  • If you see YAML parsing errors, check your indentation matches the "double check" tab above exactly.

help me debug my Docker Compose setup for a FastAPI and PostgreSQL stack

Your multi-service development environment is running. Next up, you will push your production image to Amazon ECR so it is ready for cloud deployment.

Push Your Image to Amazon ECR

Your containerized API is running beautifully on your machine with Docker Compose. The next question is: how does AWS get access to your Docker image?

You need a container registry that stores your image in the cloud so AWS services can pull it. Amazon ECR (Elastic Container Registry) is AWS's own container registry. In this step, you'll create a repository, authenticate Docker, and push your image up to ECR.

In this step, get ready to:
  • Create an ECR repository to store your Docker image.
  • Authenticate Docker to your private ECR registry.
  • Tag and push your production image to ECR.
Create an ECR repository

An ECR repository is like a folder for your Docker images. Each project typically gets its own repository, and each push creates a new version (tagged image) inside it.

  • Create a new ECR repository by running this command:
aws ecr create-repository --repository-name task-manager-api --region [[AWS_REGION="your AWS region e.g. us-east-1"]]

What does this command do?

  • aws ecr create-repository creates a new private image repository in your AWS account.
  • --repository-name task-manager-api gives the repository a human-readable name that matches your project.
  • --region specifies which AWS region to create the repository in. Use the same region you configured with aws configure in Step 1.

You should see JSON output containing a repositoryUri field. It looks like this:

"repositoryUri": "123456789012.dkr.ecr.us-east-1.amazonaws.com/task-manager-api"
  • Copy your repositoryUri value and save it somewhere. You'll need it for the next two commands.

Find your account ID

Your 12-digit AWS account ID is the number at the start of the repositoryUri (before .dkr.ecr). You'll use it in the next commands.

Seeing an error about authorization or repository?

  • Make sure your AWS CLI credentials are configured correctly. Run aws sts get-caller-identity to verify your identity.
  • If you see "RepositoryAlreadyExistsException", the repository was already created. You can continue to the next substep.

Why is my ECR create-repository command failing?

Authenticate Docker to ECR

ECR is a private registry. Before Docker can push images to it, you need to log in using your AWS credentials.

  • Authenticate Docker to your ECR registry by running this command:
aws ecr get-login-password --region [[AWS_REGION="your AWS region e.g. us-east-1"]] | docker login --username AWS --password-stdin [[AWS_ACCOUNT_ID="your 12-digit AWS account ID"]].dkr.ecr.[[AWS_REGION="your AWS region e.g. us-east-1"]].amazonaws.com

What does this command do?

  • aws ecr get-login-password generates a temporary authentication token from your AWS credentials.
  • The | pipe feeds that token into docker login.
  • --password-stdin tells Docker to read the password from the piped input rather than prompting you to type it.

You should see Login Succeeded in your terminal output.

Not seeing Login Succeeded?

  • Double-check that Docker Desktop is running. The docker login command needs the Docker daemon to be active.
  • Verify your account ID is correct. It should be the 12-digit number from the repositoryUri output above.
  • Make sure your region matches exactly what you used in the create-repository command.

Help me fix my ECR Docker login.

Tag and push your image to ECR

Docker needs your image to be named with the full ECR repository URI before it can push. This is called tagging.

  • Tag your local image with the ECR repository URI by running:
docker tag task-manager-api:latest [[AWS_ACCOUNT_ID="your 12-digit AWS account ID"]].dkr.ecr.[[AWS_REGION="your AWS region e.g. us-east-1"]].amazonaws.com/task-manager-api:latest

What does this command do?

docker tag doesn't create a new image. It adds a second name (like an alias) pointing to the same image. The new name includes the full ECR URI so Docker knows where to push it.

  • Push the tagged image to ECR by running:
docker push [[AWS_ACCOUNT_ID="your 12-digit AWS account ID"]].dkr.ecr.[[AWS_REGION="your AWS region e.g. us-east-1"]].amazonaws.com/task-manager-api:latest

What's happening during the push?

Docker uploads each layer of your image separately. You'll see progress bars for each layer. Because your multi-stage build keeps the runtime image small, this upload is faster than pushing a full build image would be.

The push takes a minute or two depending on your internet speed. When it finishes, you'll see a digest hash and a latest: digest: sha256:... line confirming your image is stored in ECR.

  • Verify your image is in ECR by opening the AWS Console in your browser.
  • Search for ECR in the top search bar and select Elastic Container Registry.
  • Click on the task-manager-api repository. You should see an image with the latest tag listed.

Push command failing or timing out?

  • If you see "no basic auth credentials" or "denied", your login token may have expired. Re-run the aws ecr get-login-password command from the previous substep.
  • If you see "name unknown" or "repository does not exist", make sure the repository name in your tag matches task-manager-api exactly.
  • If the push is very slow, check your internet upload speed. Large layers can take several minutes on slower connections.

Help me troubleshoot my Docker push to ECR.

Your Docker image is now stored in the cloud and ready for AWS services to pull. Next up, you'll deploy this image as a running container on ECS Fargate.

Deploy to ECS Fargate

Your container image is sitting in Amazon ECR, ready to run. Now you need something to actually run it.

ECS Fargate is a serverless container orchestrator. You tell it which image to run and how much CPU and memory to allocate, and it handles the rest. In this step, you'll create a cluster, define your task, and launch a service that makes your API accessible from the internet.

In this step, get ready to:
  • Create an ECS cluster via the AWS Console.
  • Create a task definition referencing your ECR image.
  • Launch a Fargate service and verify your app is publicly accessible.
Create an ECS cluster

An ECS cluster is a logical grouping for your tasks and services. For Fargate workloads, the cluster itself doesn't contain any servers. It's a namespace that organizes your running containers.

  • Open the AWS Management Console in your browser and sign in to your AWS account.
  • In the search bar at the top, type ECS and select Elastic Container Service.
  • In the left navigation pane, click Clusters.
  • Click Create cluster.
  • For Cluster name, enter task-manager-cluster.
  • Leave all other settings at their defaults.
  • Click Create.

What just happened?

The console automatically associates the Fargate and Fargate Spot capacity providers with your new cluster. This means any task you launch in this cluster can run on Fargate without any additional infrastructure setup.

  • Wait for the cluster status to show Active. This usually takes under 30 seconds.

Cluster creation failed?

If you see a permissions error, make sure your IAM user has the AmazonECS_FullAccess policy attached (or administrator access). The console uses CloudFormation to create cluster resources behind the scenes.

Help me fix my ECS cluster permissions error.

Create a task definition

A task definition is a blueprint that tells ECS exactly how to run your container. It specifies which image to pull, how much CPU and memory to allocate, which ports to expose, and which IAM role to use for pulling from ECR.

  • In the left navigation pane, click Task definitions.
  • Click Create new task definition.
  • Select Create new task definition with JSON.
  • Clear any pre-populated JSON in the editor, then paste the following task definition:
{
  "family": "task-manager-api",
  "networkMode": "awsvpc",
  "requiresCompatibilities": ["FARGATE"],
  "cpu": "256",
  "memory": "512",
  "executionRoleArn": "arn:aws:iam::[[AWS_ACCOUNT_ID=\"your 12-digit AWS account ID\"]]:role/ecsTaskExecutionRole",
  "containerDefinitions": [
    {
      "name": "task-manager-api",
      "image": "[[ECR_IMAGE_URI=\"your-account-id.dkr.ecr.your-region.amazonaws.com/task-manager-api:latest\"]]",
      "essential": true,
      "portMappings": [
        {
          "containerPort": 8000,
          "protocol": "tcp"
        }
      ]
    }
  ]
}

What does this task definition do?

This JSON tells ECS everything it needs to run your container on Fargate:

  • "cpu": "256" allocates 0.25 vCPU. "memory": "512" allocates 0.5 GB of RAM. This is the smallest (and cheapest) Fargate configuration.
  • "networkMode": "awsvpc" gives the task its own elastic network interface with a dedicated IP address. This is required for Fargate.
  • "executionRoleArn" points to the ecsTaskExecutionRole IAM role, which grants ECS permission to pull your image from ECR on your behalf.
  • "containerPort": 8000 exposes the port your FastAPI app listens on.

Find your ECR image URI

Your image URI follows the format <account-id>.dkr.ecr.<region>.amazonaws.com/task-manager-api:latest. You can find it in the ECR console under your task-manager-api repository, or by running:

aws ecr describe-repositories --repository-names task-manager-api --query 'repositories[0].repositoryUri' --output text
  • Replace the executionRoleArn value with your actual 12-digit AWS account ID.
  • Replace the image value with your full ECR image URI from the previous step.
  • Click Create.

What is the ecsTaskExecutionRole?

This is an IAM role that grants the ECS agent permission to pull container images from ECR and send logs to CloudWatch. If your account doesn't already have this role, the ECS console can create it automatically when you have administrator access.

The role uses the AWS managed policy AmazonECSTaskExecutionRolePolicy. You can check if it already exists by searching for ecsTaskExecutionRole in the IAM console.

Task definition failed to create?

The most common error is an invalid executionRoleArn. Double-check your 12-digit AWS account ID has no typos, and that ecsTaskExecutionRole exists in your account.

If the role doesn't exist yet, go to the IAM console and click Roles. Click Create role, choose Elastic Container Service Task as the use case, attach the AmazonECSTaskExecutionRolePolicy policy, and name the role ecsTaskExecutionRole.

Help me debug my task definition error.

Launch a Fargate service and verify your API

A service tells ECS to keep a specified number of copies of your task running at all times. If a task crashes, the service automatically replaces it. You'll create a service with one task and a public IP so you can reach your API from your browser.

  • In the left navigation pane, click Clusters.
  • Click on task-manager-cluster to open it.
  • Select the Services tab, then click Create.

The Create service page appears with several configuration sections.

  • Under Compute configuration, leave the default (which uses the Fargate capacity provider strategy).
  • Under Deployment configuration, for Task definition, select task-manager-api from the Family dropdown.
  • For Service name, enter task-manager-service.
  • For Desired tasks, enter 1.

Now configure the networking section so your task gets a public IP address.

  • Expand the Networking section.
  • For VPC, select your default VPC.
  • For Subnets, select at least one public subnet (the defaults are usually fine).
  • For Security group, choose Create a new security group.
  • Enter task-manager-sg as the security group name.
  • Enter Allow inbound traffic on port 8000 as the description.
  • Add an inbound rule: set Type to Custom TCP, Port range to 8000, and Source to Anywhere (0.0.0.0/0).
  • For Public IP, make sure it is turned on. This assigns a public IP to your Fargate task so it's reachable from the internet.
  • Leave all other settings at their defaults.
  • Click Create.

Why a public IP instead of a load balancer?

In production, you'd place an Application Load Balancer (ALB) in front of your Fargate tasks. An ALB gives you a stable DNS name, SSL termination, and distributes traffic across multiple tasks.

For this project, a public IP directly on the task keeps things simple and avoids the extra cost (~$0.02/hour for an ALB). The deployment concepts are identical either way.

ECS will now pull your image from ECR and start the container. This can take 1-3 minutes.

  • On the task-manager-service page, click the Tasks tab.
  • Wait until the task status shows Running.
  • Click on the task ID (the blue link) to open the task details.
  • In the Configuration section, find the Public IP field and copy the IP address.
  • Open a new browser tab and navigate to http://<your-public-ip>:8000/health (replace <your-public-ip> with the IP you just copied).

You should see a JSON response like:

{"status": "healthy", "timestamp": "2026-05-20T12:34:56.789012"}

Your FastAPI app is now running live on AWS, accessible from anywhere in the world.

Task stuck in PENDING or STOPPED?

  • If the task is stuck in PENDING, the subnets you selected might be private (no internet gateway). Go back and choose subnets associated with a public route table, or check that your default VPC subnets have auto-assign public IP enabled.
  • If the task moves to STOPPED, click on the task and check the Stopped reason field. A common cause is the ecsTaskExecutionRole lacking permissions to pull from your ECR repository.
  • If you can't reach the public IP on port 8000, verify the security group inbound rule allows TCP traffic on port 8000 from 0.0.0.0/0.

Help me debug my Fargate deployment.

Container orchestration and horizontal scaling

ECS is a container orchestrator. Its job is to make reality match your desired state. You told it "run 1 copy of this container", and it did. If that container crashes, ECS automatically launches a replacement.

Because your API is stateless (no session data stored between requests), you could change the desired count to 10 and ECS would spin up 10 identical containers. Add a load balancer in front, and you have a horizontally scaled system. This is the core pattern interviewers ask about: stateless app + orchestrator + load balancer = elastically scalable system.

Your containerized FastAPI app is now running in the cloud on AWS ECS Fargate. You've completed the full journey from local code to production deployment. Next up, the Secret Mission challenges you to add a Redis caching layer to your local development stack.

Secret mission

Add Redis Caching

Your API works, but every request hits the data source directly. In production, backend engineers add a caching layer to reduce load and speed up responses. Your secret mission: add Redis to your Docker Compose stack and implement the cache-aside pattern on your tasks endpoint.

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. Your Fargate task costs approximately $0.004/hour while running. The ECR image is free under 500 MB of storage.

Cost warning

If you leave your ECS Fargate service running 24/7, it costs approximately $3/month for the 0.25 vCPU / 0.5 GB configuration. Delete the ECS service when you are done to avoid ongoing charges.

Resources you used:

  • ECS Fargate service (running task billed per second).
  • ECS cluster (task-manager-cluster).
  • ECS task definition.
  • ECR repository (task-manager-api).
  • Local project files and Docker Compose containers/volumes.

Keep everything running

No action needed. Choose this if you are still actively building or testing your deployment.

  • Your Fargate task continues running and remains accessible at its public IP.
  • Your ECR image stays stored (free under 500 MB).
  • Docker Compose containers continue running locally if you have not stopped them.
  • AWS CLI and Docker Desktop remain installed on your machine.

Pause - I'll come back to this later

Stop the running Fargate task to avoid per-second charges while keeping your ECR image and local files intact.

  • Delete the ECS service (this stops the running task) by running:
aws ecs delete-service --cluster task-manager-cluster --service [[ECS_SERVICE_NAME="your ECS service name"]] --force
  • Stop your local Docker Compose stack by running:
docker compose down

Your ECR image and ECS cluster remain in place. You can redeploy later by creating a new service from the existing task definition.

Delete - I don't want to use this again

Remove all project resources and start fresh. Follow these steps in order.

  • Delete the ECS service by running:
aws ecs delete-service --cluster task-manager-cluster --service [[ECS_SERVICE_NAME="your ECS service name"]] --force
  • Wait about 30 seconds for the service tasks to drain, then delete the ECS cluster by running:
aws ecs delete-cluster --cluster task-manager-cluster
  • Deregister the task definition by running:
aws ecs deregister-task-definition --task-definition [[TASK_DEF_FAMILY="your-task-definition-family:1"]]
  • Delete the deregistered task definition by running:
aws ecs delete-task-definitions --task-definitions [[TASK_DEF_FAMILY="your-task-definition-family:1"]]
  • Delete the ECR repository and all images inside it by running:
aws ecr delete-repository --repository-name task-manager-api --force
  • Stop and remove your local Docker Compose containers and volumes by running:
docker compose down -v
  • Delete your local project folder. On macOS or Linux, run:
cd ~/Desktop
rm -rf [[PROJECT_FOLDER="your project folder name"]]
  • On Windows, run:
cd ~\Desktop
Remove-Item -Recurse -Force [[PROJECT_FOLDER="your project folder name"]]

Total cost for this project if you delete now: under $0.05.

That's a Wrap!

That's a Wrap!

Nice work! You just shipped a production-grade containerized API to AWS.

You've learned how to:

  • Write a production-optimized Dockerfile with multi-stage builds, a non-root user, and health checks that produce an image ~60% smaller than a naive approach.
  • Orchestrate a multi-service local development environment with Docker Compose (FastAPI + PostgreSQL) using health-check-based service dependencies.
  • Deploy a containerized app to AWS ECS Fargate through Amazon ECR, making your API publicly accessible on the internet.
  • Secret Mission: Add a Redis caching layer to your Docker Compose stack and implement the cache-aside pattern with automatic invalidation on writes.

Ready to quiz yourself?