Skip to main content

Command Palette

Search for a command to run...

From Terraform to a Running Container: Deploying an ECR Image to AWS EC2

Updated
12 min readView as Markdown
From Terraform to a Running Container: Deploying an ECR Image to AWS EC2

When I started learning DevOps, I initially thought of infrastructure provisioning and application deployment as two separate problems.

Terraform creates the infrastructure.

Docker runs the application.

AWS hosts the infrastructure.

But the more I worked through my DevOps project, the more I realized that these pieces only become meaningful when they work together.

So for this milestone, I wanted to answer a practical question:

Can I provision AWS infrastructure with Terraform, store my application image in Amazon ECR, and then deploy that image directly to an EC2 instance?

The answer is yes.

And more importantly, I learned what each component is actually responsible for.


The Goal

The objective was to build an AWS deployment flow where:

Terraform
    ↓
AWS Infrastructure
    ↓
EC2 Instance
    ↓
Pull Docker Image from ECR
    ↓
Run Application Container
    ↓
Connect to MongoDB
    ↓
Application Health Check

The application is an Expense Tracker backend built with Node.js and MongoDB.

The Docker image was built beforehand and pushed to Amazon Elastic Container Registry (ECR).

Terraform was then responsible for the AWS infrastructure required to run it.


1. Structuring the Terraform Project

Before deploying anything, I wanted the Terraform project to have a structure that could scale beyond a single resource.

The project follows a modular approach:

terraform/
└── labs/
    └── aws/
        ├── environments/
        │   └── dev/
        │       ├── main.tf
        │       ├── variables.tf
        │       ├── terraform.tfvars
        │       ├── outputs.tf
        │       ├── providers.tf
        │       ├── versions.tf
        │       ├── backend.tf
        │       └── iam.tf
        │
        └── modules/
            ├── storage/
            ├── networking/
            ├── security/
            ├── compute/
            └── ecr/

This separation helped me understand an important Terraform concept:

The root module composes the infrastructure, while child modules own specific responsibilities.

For example:

  • networking manages the VPC and subnet

  • security manages the security group

  • compute manages the EC2 instance

  • storage manages S3

  • ecr manages the container registry

  • iam.tf manages the IAM resources required by the environment

This makes adding another resource much more deliberate.


2. Terraform's Role

One of the biggest lessons from this milestone was understanding what Terraform should and should not do.

Terraform was responsible for provisioning the infrastructure.

That included resources such as:

  • VPC

  • Subnet

  • Route table

  • Internet Gateway

  • Security Group

  • EC2 instance

  • IAM role and instance profile

  • ECR repository

  • S3 resources

Terraform was not responsible for building my Docker image or manually starting the application container.

That distinction became very important.

The infrastructure layer and application runtime layer are different concerns.


3. Amazon ECR as the Artifact Registry

Once the Docker image was built, I pushed it to Amazon ECR.

The repository was:

expense-tracker-dev-backend

The image was tagged:

1.0.0

The resulting image reference was:

748241.dkr.ecr.us-east-1.amazonaws.com/expense-tracker-dev-backend:1.0.0

This changed the deployment model.

Instead of copying source code to the EC2 server and building the application there, the server could simply retrieve the already-built artifact.

That means:

Developer/CI
    ↓
Build Docker Image
    ↓
Push Image
    ↓
ECR
    ↓
EC2 pulls image
    ↓
Run container

The EC2 instance doesn't need to rebuild the application.

It consumes the artifact.


4. IAM: Giving EC2 Permission to Pull from ECR

An EC2 instance needs permission to access ECR.

Instead of storing AWS credentials inside the server, I attached an IAM role to the EC2 instance through an instance profile.

The role was given permission to:

ecr:GetAuthorizationToken
ecr:BatchCheckLayerAvailability
ecr:GetDownloadUrlForLayer
ecr:BatchGetImage
ecr:DescribeImages

This allowed the EC2 instance to authenticate with ECR and retrieve the application image.

This reinforced another important DevOps principle:

Use IAM roles for AWS workloads instead of embedding long-lived AWS access keys inside applications or servers.


5. The Application Dependency Problem

At this point, I encountered an important architectural question.

The backend application uses MongoDB.

But the Docker image only contains the backend application.

It does not contain MongoDB.

So simply running:

docker run ...

for the backend would not be enough.

The application needs a MongoDB instance that it can reach over the Docker network.

This is where understanding application dependencies became important.

The architecture became:

EC2
│
├── Docker Network
│
├── MongoDB Container
│
└── Backend Container
       │
       └── connects to MongoDB

6. Creating the Docker Network

I created a dedicated Docker network on the EC2 instance:

docker network create expense-network

I then verified that the network existed:

docker network ls

The purpose of the network was to allow the containers to communicate with each other using Docker's internal networking.


7. Running MongoDB

MongoDB was started as a separate container:

docker run -d \
  --name mongodb \
  --network expense-network \
  -v mongo-data:/data/db \
  --restart unless-stopped \
  mongo:4.4

One deliberate decision here was not to publish MongoDB's port to the public internet.

MongoDB only needed to be reachable by the backend container.

The backend could therefore communicate with:

mongodb:27017

through the Docker network.

This is much better than unnecessarily exposing the database publicly.


8. Running the Backend from ECR

With MongoDB running, I pulled and ran the application image from ECR.

The backend container was started with:

docker run -d \
  --name backend \
  --network expense-network \
  -p 3000:3000 \
  -e APP_ENV=docker \
  -e PORT=3000 \
  -e MONGO_URL=mongodb://mongodb:27017/expense-tracker \
  --restart unless-stopped \
  748241.dkr.ecr.us-east-1.amazonaws.com/expense-tracker-dev-backend:1.0.0

The important part here is that the container is using the image that already exists in ECR.

The EC2 instance is not building the image.

It is consuming the image.


9. Verifying the Containers

I checked the running containers with:

docker ps

The result showed both:

backend
mongodb

running on the same Docker network.

The backend exposed:

3000:3000

while MongoDB remained internal to the Docker network.


10. Checking the Application Logs

Next, I checked the backend logs:

docker logs backend

The application reported:

Starting application with 'local' configuration
MongoDB Connected to mongodb
Server is listening on http://localhost:3000

The most important line was:

MongoDB Connected to mongodb

This proved that the backend container could successfully communicate with the MongoDB container through Docker networking.


11. An Unexpected Configuration Observation

There was also an interesting configuration issue.

I passed:

-e APP_ENV=docker

when starting the container.

However, the application logs still showed:

APP_ENV=local

The reason was in the application's package.json.

The start script contained:

"start": "APP_ENV=local node server.js"

Therefore, the start command itself was explicitly setting APP_ENV to local.

This was a useful debugging lesson.

It reminded me that when environment variables don't behave as expected, I need to inspect the entire configuration chain:

Docker environment
        ↓
Container startup command
        ↓
package.json
        ↓
Application code
        ↓
Configuration file

The important thing is that the application still successfully connected to MongoDB because the explicitly supplied MONGO_URL was used.


12. The Final Health Check

The final test was:

curl http://localhost:3000/health

The application returned:

{
  "status": "UP",
  "message": "Expense Tracker Backend is healthy"
}

At this point, the complete deployment path was working.


13. What I Had Actually Built

The final architecture looked like this:

                 Developer / CI
                       │
                       │ Docker Image
                       ▼
                 Amazon ECR
                       │
                       │ Pull
                       ▼
              ┌─────────────────┐
              │      AWS EC2     │
              │                  │
              │  Docker Network  │
              │                  │
              │  ┌────────────┐  │
              │  │  Backend   │  │
              │  │  :3000     │  │
              │  └─────┬──────┘  │
              │        │         │
              │        ▼         │
              │  ┌────────────┐  │
              │  │  MongoDB   │  │
              │  │  :27017    │  │
              │  └────────────┘  │
              └─────────────────┘
                       │
                       ▼
                  Health Check

And Terraform sits underneath the infrastructure layer:

Terraform
    │
    ├── VPC
    ├── Subnet
    ├── Route Table
    ├── Internet Gateway
    ├── Security Group
    ├── EC2
    ├── IAM
    └── ECR

14. Terraform vs Docker

This milestone helped me clearly separate the responsibilities of the tools.

Terraform

Terraform answers:

What infrastructure should exist?

It provisions and manages AWS resources.

Docker

Docker answers:

How should the application run?

It packages and runs the application and its runtime dependencies.

ECR

ECR answers:

Where should the container artifact be stored?

It provides a registry for Docker images.

EC2

EC2 answers:

Where should the container actually run?

It provides the compute environment.

This distinction is simple, but understanding it has completely changed how I think about DevOps architecture.


15. The Deployment Flow

The complete flow can now be summarized as:

Application Source Code
        ↓
Docker Build
        ↓
Docker Image
        ↓
Amazon ECR
        ↓
Terraform-Provisioned EC2
        ↓
EC2 Authenticates to ECR
        ↓
Docker Pull
        ↓
MongoDB Container Starts
        ↓
Backend Container Starts
        ↓
Backend Connects to MongoDB
        ↓
Health Check
        ↓
Application Running

16. What I Learned

This milestone taught me much more than simply running a Terraform command.

1. Infrastructure and application deployment are different layers

Terraform provisions the infrastructure.

Docker runs the application.

Understanding this separation makes architecture much easier to reason about.

2. An image is an artifact

Once the image is pushed to ECR, the deployment server does not need to rebuild it.

It retrieves the artifact and runs it.

3. Applications have dependencies

The backend image did not magically include MongoDB.

I had to identify the runtime dependency and make it available.

4. Docker networking matters

Containers can communicate using service/container names through a Docker network.

In this case:

mongodb:27017

was enough for the backend to reach MongoDB.

5. IAM roles are important

The EC2 instance accessed ECR through an IAM role rather than hard-coded AWS credentials.

6. Debugging requires following the entire chain

The APP_ENV behavior was a good example.

The problem wasn't necessarily Docker.

The application's own startup command was overriding the environment variable.

7. Health checks matter

A container being Up doesn't automatically mean the application is working.

The health endpoint gave me a much stronger confirmation:

{"status":"UP","message":"Expense Tracker Backend is healthy"}

17. What I Would Improve for Production

This deployment works, but I wouldn't call it production-ready yet.

There are several areas I would improve.

Security

SSH should not be unnecessarily exposed to the entire internet.

The application should also have a properly designed ingress strategy rather than exposing services indiscriminately.

Configuration

The application should avoid hard-coding environment selection inside the startup script.

Configuration should be controlled consistently through the deployment environment.

Deployment Automation

The Docker commands were executed manually.

The next step is to automate this process.

Image Immutability

For stronger production deployments, I would prefer deploying using immutable image references such as image digests rather than relying only on mutable tags.

Database Architecture

Running MongoDB as a container on the same EC2 instance is appropriate for this learning environment, but a production system would require a more resilient database architecture.


18. The Next Evolution: CI/CD

This milestone also gives me a clear picture of where CI/CD fits.

Today, the process is:

Developer
   ↓
Build Image
   ↓
Push to ECR
   ↓
SSH to EC2
   ↓
Pull Image
   ↓
Run Container

The next evolution is:

Developer
   ↓
GitHub
   ↓
CI Pipeline
   ↓
Test
   ↓
Build Docker Image
   ↓
Push to ECR
   ↓
Deployment Automation
   ↓
EC2
   ↓
Pull Image
   ↓
Run New Version

This is where Jenkins or GitHub Actions becomes valuable.

The manual deployment I completed here becomes the baseline that automation will reproduce.

That is an important distinction.

I don't want to automate something I don't understand.

First:

Make it work manually.

Then:

Understand every step.

Then:

Automate the repeatable steps.


19. The Terraform CI/CD Flow

Terraform itself can also be integrated into a CI/CD workflow.

A future Terraform pipeline could look like:

Git Push
   ↓
terraform fmt
   ↓
terraform validate
   ↓
terraform plan
   ↓
Review
   ↓
terraform apply

This creates a more controlled infrastructure delivery process.

The important principle is that infrastructure changes should be reviewed before they are applied.


20. If I Were Asked About This in an Interview

If an interviewer asked:

"How would you deploy a Dockerized application to AWS using Terraform and ECR?"

I could now explain it from experience rather than theory:

I would use Terraform to provision the AWS infrastructure, including the VPC, subnet, security group, EC2 instance, IAM role, and ECR repository. The application would be packaged as a Docker image and pushed to ECR. The EC2 instance would use an IAM role to authenticate to ECR and pull the image. Runtime dependencies such as MongoDB would be provided separately, and the containers would communicate through a private Docker network. The application would then be started on EC2 and verified through a health check. Once the manual flow is proven, I would automate the build, image push, and deployment using a CI/CD pipeline.

That's a much stronger answer than simply saying:

"I know Terraform and Docker."


21. The Biggest Lesson

The biggest lesson from this milestone wasn't a Terraform command.

It was understanding the relationship between the tools.

Terraform doesn't replace Docker.

Docker doesn't replace Terraform.

ECR doesn't run the application.

EC2 doesn't build the application artifact.

Each component has a responsibility.

The real DevOps skill is understanding how those responsibilities connect.


22. Final Result

At the end of this milestone, I had successfully demonstrated:

Terraform
   ↓
AWS Infrastructure
   ↓
IAM
   ↓
EC2
   ↓
ECR
   ↓
Docker
   ↓
MongoDB
   ↓
Node.js Backend
   ↓
Health Check

The application was running successfully on AWS EC2 from an image stored in ECR.

This completed an important part of my Infrastructure as Code journey.

More importantly, I now have a working baseline that can be automated.

And that's where the next chapter begins:

CI/CD.