
Every part of the KloudGov projects so far has run on EC2 provisioned by Terraform, configured by Ansible, fronted by an ALB. That architecture works, and it’s a legitimate way to run infrastructure, but EC2 isn’t the only way to run a containerized workload on AWS. This new series explores the other major path: re-platforming KloudGov onto containers, replacing EC2 with a managed container service, and building toward high availability without managing a single server directly.
This is Part 1 of a three-part series:
Part 1 (this post): containerize the KloudGov application and its Nginx web server with Docker, and push both images to Amazon Elastic Container Registry (ECR).
Part 2: Provision the supporting AWS infrastructure with Terraform: S3, DynamoDB, and an ECS cluster.
Part 3: Manually deploy the containerized application to ECS using Fargate, creating the task definition and service by hand, before automating this in later posts.
By the end of these three parts, KloudGov runs as a fully managed, scalable, resilient containerized service with no EC2 instance to patch, size, or SSH into.
Why Containerized at All
It’s wortth being explicit about what what this buys over the EC2-based approach from the earlier series with EC2, every state’s application depends on an instance that has to be provisioned, connected to configured with Ansible, and kept patched with containers and ECS Fargate, AWS manages the underlying compute entirely, there’s no EC2 instance to reason about, no OS to patch, and scalling becomes a matter of adjusting task count rather than provisioning new servers. The tradeoff is a different kind of complexity. Instead of systemd units and Nginx configs living on a filesystem, everything the application needs to run gets baked into a Docker image, versioned and stored in ECR.
Prerequisite: Creating an IAM Role for ECS Task Execution
Before touching Docker, it’s worth setting up one IAM piece that Part 3 will need: a dedicated ECS execution role. This mirrors exactly what the EC2-based series did with s3_dynamodb_full_access_role, just scoped for ECS instead of EC2:
In the IAM console, create a new role:
Trusted entity type: AWS Service
Service or use case: Elastic Container Service
Use case: Elastic Container Service Task
Permissions:
AmazonS3FullAccess
AmazonDynamoDBFullAccess
AmazonECSTaskExecutionRolePolicy
Role name: KloudGovECSExecutionRole
The third policy, AmazonECSTaskExecutionRolePolicy, is the one that’s new relative to the EC2 series; it allows ECS to pull container images from ECR and write logs to CloudWatch on the application’s behalf, separate from the application’s own permissions to talk to S3 and DynamoDB. This role won’t be used until Part 3, but setting it up now means it’s ready when the ECS task definition needs it.
Step 1: Creating the Application Dockerfile
Inside the existing kloud-gov-application repository, create a Dockerfile for the Flask application:
cd kloud-gov-application/src && touch Dockerfile
# Dockerfile # Use Python as the base image FROM python:3.11-slim-bookworm # Set the working directory WORKDIR /app # Copy requirements file and install dependencies # --no-cache-dir prevents pip from saving temporary files, keeping the image lightweight COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt # Copy the Flask application files COPY . /app # Start Gunicorn server, binding to all network interfaces on port 8000 CMD ["gunicorn", "--workers", "1", "--bind", "0.0.0.0:8000", "kloudgov:app"]
A few details worth understanding, not just copying:
kloudgov:app in the CMD line refers to kloudgov.py, the application’s entry point kloudgov is the module name, and app is the actual Flask instance defined inside it (app = Flask(__name__)). If your entry-point filename differs, it must match exactly, or Gunicorn will fail to find the application on container startup.
–no-cache-dir on the pip install keeps the resulting image smaller by not persisting pip’s download cache inside a layer that never gets used again after the initial install.
Copying requirements.txt before the rest of the application code is a deliberate ordering choice. Docker caches each layer, so as long as requirements.txt doesn’t change between builds, Docker reuses the cached dependency-install layer instead of re-running pip install every single time you rebuild after an unrelated code change. Small detail, meaningful difference in build speed once you’re iterating.
Step 2: Creating the Application Repository in ECR
In the ECR console, create a new repository:
Repository name: kloudgov-app
This repository will store every version of the application’s Docker image, tagged and versioned, ready for ECS to pull in Part 3.
Step 3: Building and Pushing the Application Image
ECR provides exact push commands per repository in the console; select kloudgov-app and click View push commands, which generates the authenticate/build/tag/push sequence specific to your account and region. Run them in order.

A Real Debugging Detour Worth Documenting
If you’re working from a fresh Amazon Linux EC2 IDE instance (the same one used throughout this project), Docker likely isn’t installed yet, and the very first push command will fail immediately:
Error: docker: command not found
Install and start Docker
sudo yum install -y docker docker ps sudo systemctl status docker sudo systemctl start docker sudo systemctl status docker # Press 'q' to exit the status pager
Running docker ps again at this point surfaces a second, equally common issue:
docker ps
# permission denied while trying to connect to the Docker daemon socket
By default, only root and members of the docker group can talk to the Docker daemon. Add your user to that group, and refresh the current shell session’s group membership without needing to log out fully:
sudo usermod -aG docker $USER newgrp docker docker ps
This should now run cleanly. With Docker actually functional, re-run the ECR push commands from the console: build, tag, authenticate, and push the image. Once complete, confirm the image landed correctly by checking the kloudgov-app repository in the ECR console for a new image tag.
Step 4: Creating the Web Server Files
The application needs a reverse proxy in front of it, the same role Nginx played in the EC2-based series, just packaged as its own container this time instead of installed directly on a host.
cd .. pwd # /home/ec2-user/kloud-gov-application mkdir nginx && touch nginx.conf proxy_params Dockerfile
nginx.conf
# nginx/nginx.conf
server {
listen 80;
server_name kloudgov www.kloudgov;
location / {
include proxy_params;
proxy_pass http://localhost:8000;
}
}
This configures Nginx to listen on port 80 and forward every request to the Flask backend running on localhost:8000, the same port Gunicorn binds to inside the application container. Note this deliberately skips Nginx’s default configuration file entirely, same as the EC2-based deployment did back in Part 2 of the earlier series a clean, project-specific config from the start rather than one built by removing defaults.
proxy_params
# nginx/proxy_params proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
This file defines the HTTP headers Nginx passes through to the Flask backend:
Host preserves the original hostname the client requested, so the backend doesn’t see localhost.
X-Real-IP passes the actual client IP address through; without it, the backend would only see Nginx’s internal address as the request origin.
X-Forwarded-For maintains the chain of IP addresses as a request passes through any proxies, useful for logging and debugging in more complex network paths.
Together, these three headers make Nginx function as a proper reverse proxy rather than a dumb pass-through: a reverse proxy receives client requests and forwards them to an internal backend, hides the real server, and, depending on configuration, can add load balancing, security, or caching along the way. In this project, its job is simpler: sit in front of Flask, forward requests correctly, and preserve the client information the backend needs.
Dockerfile
# nginx/Dockerfile # Use the NGINX Alpine image as the base FROM nginx:alpine # Remove the default NGINX configuration file RUN rm /etc/nginx/conf.d/default.conf # Copy the custom NGINX configuration file COPY nginx.conf /etc/nginx/conf.d # Copy the proxy parameters file COPY proxy_params /etc/nginx/proxy_params # Expose port 80 EXPOSE 80 # Start NGINX in the foreground CMD ["nginx", "-g", "daemon off;"]
Two choices here worth understanding:
nginx:alpine as the base image keeps this container small. Alpine-based images are a fraction of the size of full Debian/Ubuntu-based equivalents, which matters for both image pull speed and storage cost in ECR.
daemon off; is not optional in a container context. Nginx normally runs as a background daemon, but a container’s main process needs to stay in the foreground. If Nginx daemonizes and the foreground process exits, the container considers its job done and terminates immediately, even if Nginx is still running in the background.
Step 5: Creating the Web Server Repository in ECR
Same process as Step 2, this time for the Nginx image:
Repository name: kloudgov-nginx

Step 6: Building and Pushing the Web Server Image
Same pattern as Step 3: select the kloudgov-nginx repository in the ECR console, click View push commands, and run the generated sequence from inside the nginx directory. Since Docker is already installed and permissions are already configured from Step 3, this push should complete without the detour this time.
Once finished, confirm the image landed correctly by checking the kloudgov-nginx repository in the ECR console.
What This Sets Up for Part 2 and Part 3
At the end of this post, two fully built, tested Docker images sit in ECR: one for the Flask application, one for the Nginx reverse proxy in front of it, along with an IAM execution role ready for ECS to use. Nothing has been deployed to AWS’s compute layer yet; that’s deliberate. Part 2 provisions the supporting infrastructure (S3, DynamoDB, the ECS cluster itself) with Terraform, and Part 3 brings these two images together into a running ECS service.
Key Takeaways
Containerizing an application separates what the application needs to run (baked into the image) from where it runs (EC2 today, ECS/Fargate by Part 3); that separation is the entire point of this architectural shift.
Layer ordering in a Dockerfile isn’t cosmetic; copying requirements.txt before application code lets Docker cache the dependency-install layer, meaningfully speeding up rebuilds during iteration.
docker: command not found and permission denied connecting to the Docker daemon are two of the most common first-run issues on a fresh Amazon Linux instance; installing Docker and adding your user to the docker group resolves both.
A container’s main process must run in the foreground (daemon off; for Nginx). Any process that daemonizes itself will cause the container to exit immediately, since Docker considers the container’s job done the moment its foreground process exits.
Setting up IAM roles ahead of when they’re actually used (like KloudGovECSExecutionRole here, not needed until Part 3) keeps later steps focused purely on the task at hand, rather than context-switching into IAM configuration mid-deployment.
With both images built and safely stored in ECR, Part 2 shifts back to Terraform provisioning the S3 bucket, DynamoDB table, and ECS cluster that these containers will eventually run inside.

