After deploying a Java application to DigitalOcean (Project 1) and setting up Nexus Repository Manager (Project 2), I moved on to what might be the most fundamental shift in how modern applications are deployed: containerization.
Up until this point, I'd been deploying applications the traditional way—install dependencies on a server, copy files, configure environment variables, hope it works. Docker changes all of that. The promise is simple: if it runs in a container on your laptop, it'll run the same way anywhere. No more "works on my machine" problems.
This project had me:
- Containerizing a Node.js application with MongoDB and MongoExpress
- Building Docker images from scratch
- Pushing images to AWS Elastic Container Registry (ECR)
- Orchestrating multi-container applications with Docker Compose
- Understanding container networking (the hard way)
It took me an entire day (about 6–8 hours). And I learned why containers have become the de facto standard for deploying modern applications—not because they're easy, but because they solve real problems.
The Plan (and Reality)
What I thought I'd do:
- Write a Dockerfile (15 minutes)
- Build an image (5 minutes)
- Run containers with Docker Compose (10 minutes)
- Push to AWS ECR (10 minutes)
What actually happened:
- Installed Docker, hit a heredoc syntax error
- Built an image, app couldn't connect to MongoDB
- Spent 2 hours debugging container networking
- Fixed networking, MongoExpress crashed in a loop
- Pushed to ECR, hit credential storage errors
- Finally understood image immutability after rebuilding 5 times
But let's start from the beginning.
Part 1: Installing Docker (The Heredoc Problem)
I was following the Docker installation guide for Ubuntu, which had this command:
sudo tee /etc/apt/sources.list.d/docker.sources <<EOF
Types: deb
URIs: https://download.docker.com/linux/ubuntu
Suites: $(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}")
Components: stable
Signed-By: /etc/apt/keyrings/docker.asc
EOF
I carefully typed it out, hit Enter, and... nothing. The terminal just sat there with a > prompt.
I typed some more, hit Enter again. Still just > prompts.
What was happening?
Understanding Heredocs
The <<EOF syntax is called a "heredoc" (here document). It tells the shell: "I'm about to give you multiple lines of input. Keep reading until you see EOF on a line by itself."
The problem: I had been typing the command piece by piece, pressing Enter after each line, and the shell was waiting for me to finish the heredoc by typing EOF.
But I didn't know that. I thought the command had hung. So I pressed Ctrl+C to cancel it.
Then I started over and accidentally typed extra "Types" lines before pasting the real content. The file ended up with garbage in it.
Delete the file and do it right:
# Remove the malformed file
sudo rm /etc/apt/sources.list.d/docker.sources
# Paste the ENTIRE heredoc command at once, then press Enter
sudo tee /etc/apt/sources.list.d/docker.sources <<EOF
Types: deb
URIs: https://download.docker.com/linux/ubuntu
Suites: noble
Components: stable
Signed-By: /etc/apt/keyrings/docker.asc
EOF
Lesson learned: When you see <<EOF, paste the entire block including the closing EOF. Don't type it line by line.
The docker-compose vs docker compose Issue
After installing Docker, I tried to run docker-compose:
docker-compose version
Traceback (most recent call last):
File "/usr/bin/docker-compose", line 33, in <module>
...
ModuleNotFoundError: No module named 'distutils'
What was happening:
I had docker-compose V1 (the old Python-based version) installed, but it was trying to use Python 3.12, which removed the distutils module that V1 depends on.
The fix: Use Docker Compose V2 (the modern Go-based version that's built into Docker):
# Old way (broken)
docker-compose up
# New way (works)
docker compose up
Note the space instead of a hyphen. Docker Compose V2 is a Docker CLI plugin, not a separate Python script.
If you see tutorials using docker-compose (hyphen), that's the old version. Use docker compose (space) going forward.
Part 2: Local Development with Docker
The project is a simple Node.js application that connects to MongoDB and provides a web interface via MongoExpress (a MongoDB admin UI).
The architecture:
┌─────────────────────────────────────────┐
│ Your Browser │
│ localhost:3000 → Node.js app │
│ localhost:8081 → MongoExpress │
└────────────────┬────────────────────────┘
│
↓
┌─────────────────────────────────────────┐
│ Docker Network │
│ │
│ ┌──────────┐ ┌──────────┐ │
│ │ my-app │─────→│ mongodb │ │
│ │ :3000 │ │ :27017 │ │
│ └──────────┘ └──────────┘ │
│ ↑ ↑ │
│ │ │ │
│ ┌─────────────┐ │ │
│ │mongo-express│──────────┘ │
│ │ :8081 │ │
│ └─────────────┘ │
└─────────────────────────────────────────┘
Writing the Dockerfile
First, I needed to containerize the Node.js application. A Dockerfile is like a recipe for building an image—it specifies the base operating system, installs dependencies, and copies your application code.
Here's what I actually wrote:
FROM node:20-alpine
ENV MONGO_DB_USERNAME=admin \
MONGO_DB_PWD=password
RUN mkdir -p /home/app
COPY ./app /home/app
# set default dir so that next commands executes in /home/app dir
WORKDIR /home/app
# will execute npm install in /home/app because of WORKDIR
RUN npm install
# no need for /home/app/server.js because of WORKDIR
CMD ["node", "server.js"]
What this does:
FROM node:20-alpine— Uses Alpine Linux (a lightweight version, ~5MB vs ~900MB for standard)ENV— Sets environment variables for MongoDB credentials (hardcoded for learning; in production these would come from secrets management)RUN mkdir -p /home/app— Creates the application directoryCOPY ./app /home/app— Copies the entire app folder into the containerWORKDIR /home/app— Sets the working directory so subsequent commands run from hereRUN npm install— Installs dependenciesCMD ["node", "server.js"]— Starts the application
This worked fine for development, but as I learned more about Docker, I realized there's a more efficient way to structure this for better layer caching.
The optimized version would look like this:
FROM node:20-alpine
WORKDIR /home/app
# Copy package files first (for layer caching)
COPY ./app/package*.json ./
# Install dependencies
RUN npm install
# Copy application code
COPY ./app .
# The application listens on port 3000
CMD ["node", "server.js"]
Why Layer Caching Matters
Docker builds images in layers. Each instruction in the Dockerfile creates a new layer. If a layer hasn't changed, Docker reuses it from cache.
My original Dockerfile copied the entire ./app directory first, then ran npm install. This means every time I changed any file in my application (even just server.js), Docker saw the COPY ./app /home/app layer as changed, and it would reinstall all npm packages.
By copying package*.json first and running npm install before copying the rest of the application code, the dependency installation layer only rebuilds when package.json changes. Code changes don't trigger npm reinstalls.
My Dockerfile worked, but it wasn't optimized. For learning and initial development, that's fine—the rebuild time difference was negligible with only a few dependencies. But in a real project with hundreds of npm packages, the difference between a 2-second code copy and a 3-minute npm install matters.
This pattern applies to any language: Python's requirements.txt, Ruby's Gemfile, Java's pom.xml—always copy dependency files first, install them, then copy application code.
Building the Image
docker build -t my-app:1.0 .
[+] Building 45.3s (10/10) FINISHED
=> [1/5] FROM node:20
=> [2/5] WORKDIR /home/app
=> [3/5] COPY package*.json ./
=> [4/5] RUN npm install
=> [5/5] COPY . .
=> exporting to image
Successfully built 8a3f2b1c4d5e
Successfully tagged my-app:1.0
Great! I had my first Docker image.
Part 3: Running Containers (The Networking Problem)
Now I needed to run the containers. I started with just the Node.js app:
docker run -d -p 3000:3000 my-app:1.0
I opened my browser: http://localhost:3000
The page loaded! But it was blank. I refreshed. Error.
I checked the container logs:
docker logs <container-id>
MongoServerSelectionError: connect ECONNREFUSED 127.0.0.1:27017
at Timeout._onTimeout (...)
The Localhost Problem
The application was trying to connect to localhost:27017 (MongoDB), but there was no MongoDB running at localhost from the container's perspective.
Here's the critical concept I didn't understand yet:
When you run a process inside a Docker container, localhost refers to that container, not your host machine, and not other containers.
My Node.js code had:
let mongoUrlLocal = "mongodb://admin:password@localhost:27017";
This worked when I ran node server.js directly on my laptop (MongoDB was also running on my laptop). But inside a container, localhost means "this container," and there's no MongoDB in the Node.js container.
The Solution: Container Networking
Containers on the same Docker network can reach each other using container names as hostnames. Docker provides built-in DNS for this.
The fix in the code:
// ❌ This doesn't work in Docker
let mongoUrlLocal = "mongodb://admin:password@localhost:27017";
// ✅ This works - use the container/service name
let mongoUrlDockerCompose = "mongodb://admin:password@mongodb:27017";
But wait—I didn't have a container named mongodb running yet. I needed to set up the entire stack.
Part 4: Docker Compose (Orchestrating Multiple Containers)
Running containers individually with docker run is fine for testing, but real applications have multiple services that need to work together. Docker Compose lets you define an entire multi-container application in a YAML file.
I created docker-compose.yaml:
version: '3'
services:
mongodb:
image: mongo
ports:
- 27017:27017
environment:
- MONGO_INITDB_ROOT_USERNAME=admin
- MONGO_INITDB_ROOT_PASSWORD=password
mongo-express:
image: mongo-express
ports:
- 8081:8081
environment:
- ME_CONFIG_MONGODB_ADMINUSERNAME=admin
- ME_CONFIG_MONGODB_ADMINPASSWORD=password
- ME_CONFIG_MONGODB_SERVER=mongodb
my-app:
image: my-app:1.0
ports:
- 3000:3000
What Docker Compose does:
- Creates a network called
<project>_default - Connects all services to that network
- Sets up DNS so services can find each other by name
- Starts containers in dependency order (though it doesn't wait for them to be ready)
First Attempt
docker compose up
[+] Running 3/3
✔ Container mongo-express-1 Started
✔ Container mongodb-1 Started
✔ Container my-app-1 Started
Great! Everything started.
I checked localhost:3000. Blank page again. Checked logs:
docker compose logs my-app
Same error: ECONNREFUSED 127.0.0.1:27017
Why?
Because my Docker image was built with the old code that still used localhost. I had updated server.js to use mongodb, but the Docker image I built earlier didn't have that change.
Understanding Image Immutability
This is a fundamental concept that took me a while to grasp:
Docker images are immutable snapshots.
When you run docker build, it packages your code as it exists at that moment into an image. If you change your code afterward, the image doesn't magically update. You have to rebuild it.
The workflow:
- Change code
- Rebuild image:
docker build -t my-app:1.0 . - Restart containers:
docker compose down && docker compose up
I did this. Finally:
docker compose logs my-app
app listening on port 3000!
No MongoDB connection errors. I opened localhost:3000 in my browser. The application loaded.
Part 5: MongoExpress Connectivity Issues
The Node.js app was working, but when I tried to access MongoExpress at localhost:8081, it wasn't responding.
docker compose logs mongo-express
Waiting for mongo:27017...
/docker-entrypoint.sh: line 15: mongo: Name does not resolve
/docker-entrypoint.sh: line 15: /dev/tcp/mongo/27017: Invalid argument
Tue Feb 17 17:59:46 UTC 2026 retrying to connect to mongo:27017 (2/10)
...
(repeats 10 times)
No custom config.js found, loading config.default.js
Welcome to mongo-express 1.0.2
The Healthcheck Script Issue
MongoExpress has a startup healthcheck script that's hardcoded to look for a hostname called mongo, but my service is named mongodb.
The healthcheck fails 10 times (producing scary-looking errors), but then the actual MongoExpress application starts anyway using my ME_CONFIG_MONGODB_SERVER=mongodb environment variable.
The errors were just noise. The app was actually working fine.
I accessed localhost:8081 and was greeted with a login prompt.
Login credentials:
From the logs, I saw:
basicAuth credentials are "admin:pass"
These are the web UI credentials (for accessing MongoExpress itself), not the MongoDB credentials. This is a common point of confusion.
ME_CONFIG_MONGODB_ADMINUSERNAME/PASSWORD= MongoDB database credentialsME_CONFIG_BASICAUTH_USERNAME/PASSWORD= MongoExpress web UI credentials (defaults to admin/pass)
I logged in with admin / pass. MongoExpress loaded. ✅
Part 6: Pushing to AWS ECR (The Credential Storage Problem)
With everything working locally, the next step was simulating a real deployment workflow. In production, you don't build images on the server—you build them once, store them in a registry, and servers pull from there.
AWS Elastic Container Registry (ECR) is a private Docker registry. It's like Docker Hub, but:
- Private (only authorized users can pull)
- Integrated with AWS IAM
- Commonly used in AWS environments
Creating an ECR Repository
In the AWS console, I created a repository called my-app.
This gave me a repository URL: 601970634480.dkr.ecr.us-east-2.amazonaws.com/my-app
Authenticating to ECR
Before I could push images, I needed to authenticate:
aws ecr get-login-password --region us-east-2 | \
docker login --username AWS --password-stdin \
601970634480.dkr.ecr.us-east-2.amazonaws.com
error saving credentials: error storing credentials - err: exit status 1,
out: `pass not initialized: exit status 1: Error: password store is empty.
Try "pass init".`
The Credential Store Issue
Docker Desktop was trying to use pass (a Linux password manager) to securely store credentials, but pass wasn't configured.
The fix: Edit ~/.docker/config.json and remove the credential store line:
Before:
{
"auths": {},
"credsStore": "desktop",
"currentContext": "desktop-linux"
}
After:
{
"auths": {},
"currentContext": "desktop-linux"
}
This tells Docker to store credentials in plain JSON instead of using pass. Not ideal for production, but fine for learning.
I re-ran the login command:
Login Succeeded
Tagging and Pushing the Image
Docker images need to be tagged with the full registry URL to push them to a private registry.
# Tag the image
docker tag my-app:1.0 601970634480.dkr.ecr.us-east-2.amazonaws.com/my-app:1.0
# Push to ECR
docker push 601970634480.dkr.ecr.us-east-2.amazonaws.com/my-app:1.0
The push refers to repository [601970634480.dkr.ecr.us-east-2.amazonaws.com/my-app]
5f70bf18a086: Pushed
d1fe2eaf6101: Pushed
...
1.0: digest: sha256:abc123... size: 2421
My first image in a private registry. ✅
Part 7: Simulating Deployment (Pulling from ECR)
Now for the final step: simulating what a real server would do. Instead of building the image locally, I'd pull it from ECR—just like a production deployment.
I updated docker-compose.yaml to reference the ECR image:
version: '3'
services:
mongodb:
image: mongo
ports:
- 27017:27017
environment:
- MONGO_INITDB_ROOT_USERNAME=admin
- MONGO_INITDB_ROOT_PASSWORD=password
mongo-express:
image: mongo-express
ports:
- 8081:8081
environment:
- ME_CONFIG_MONGODB_ADMINUSERNAME=admin
- ME_CONFIG_MONGODB_ADMINPASSWORD=password
- ME_CONFIG_MONGODB_SERVER=mongodb
my-app:
image: 601970634480.dkr.ecr.us-east-2.amazonaws.com/my-app:1.0
ports:
- 3000:3000
The key change: my-app now references the ECR image URL instead of a local image.
Running the "Deployed" Application
# Stop any running containers
docker compose down
# Pull images and start containers
docker compose up
What happened:
- Docker Compose saw
601970634480.dkr.ecr.us-east-2.amazonaws.com/my-app:1.0 - Since it's not a Docker Hub image, Docker checked if I had it locally
- I didn't, so it pulled it from ECR (using my authenticated session)
- MongoDB and MongoExpress pulled from Docker Hub (public, no auth needed)
- All containers started
I accessed localhost:3000. The application worked.
- The "server" (my laptop, pretending to be a server) didn't build anything
- It pulled a pre-built image from a private registry
- It started containers using that immutable artifact
Part 8: Understanding Container Networking (The Deep Dive)
This project forced me to understand Docker networking in a way that reading documentation never could.
Localhost vs Container Names
When you run node server.js directly:
- Your laptop is "localhost"
- MongoDB running on your laptop is at
localhost:27017 - Everything works
When you run in Docker:
- Each container is isolated
localhostinside the Node.js container means "this container"- MongoDB is in a different container, so
localhostdoesn't work
Solution:
- Docker Compose creates a network
- Containers on the same network can reach each other by service name
mongodb(fromdocker-compose.yaml) becomes a DNS hostname
Port Mapping: Host vs Container
my-app:
ports:
- 3000:3000
What this means:
- Container listens on port 3000 internally
- Docker maps your laptop's port 3000 to the container's port 3000
- You access it at
localhost:3000
Container-to-container communication doesn't use these mappings:
my-appconnects tomongodb:27017directly- The port mapping (
27017:27017) is only for accessing MongoDB from your laptop
What I Learned (The Real Takeaways)
1. Containers Solve "Works on My Machine"
Coming from tier 1 IT support where I've seen countless deployment issues caused by environment differences (wrong Java version, missing library, different OS), containers finally make sense to me.
The promise: If it runs in a container on my laptop, it runs the same way on any server.
Why: The container includes everything—OS, runtime, dependencies, code. The server doesn't need to install anything except Docker.
2. Image Immutability Is Fundamental
Code changes don't automatically appear in running containers. You must:
- Change code
- Rebuild image
- Restart containers (or push new image and pull it)
This felt annoying at first, but it's actually a feature: you always know exactly what code is running. No "I thought I deployed that fix" confusion.
3. Container Networking Is Different
localhostmeans "this container"- Containers on the same network communicate by name
- Port mappings are for host access, not inter-container communication
This is the #1 source of confusion when containerizing apps that previously ran on localhost.
4. Private Registries Require Authentication
Docker Hub is public—anyone can pull images. ECR is private—you need to authenticate.
In production:
- CI/CD builds images and pushes to ECR
- Servers authenticate to ECR and pull images
- Images are immutable artifacts with version tags
5. Docker Compose Is Not Production Orchestration
Docker Compose is great for development and simple deployments, but it has limitations:
- Single-host only (can't spread containers across multiple servers)
- No automatic scaling
- No self-healing (if the server dies, everything dies)
Production uses:
- Kubernetes (complex, powerful, industry standard)
- AWS ECS/Fargate (simpler, AWS-native)
- Docker Swarm (simpler than K8s, less common)
Production Considerations (What This Demo Skipped)
What I Did (Learning)
- HTTP only (no SSL)
- Hardcoded credentials in docker-compose.yaml
- No persistent storage (data lost on container restart)
- Single server simulation
- Manual image building and pushing
What Production Would Add
Security:
- HTTPS with SSL certificates
- Secrets management (AWS Secrets Manager, not environment variables)
- Network policies (restrict container-to-container communication)
- Image scanning for vulnerabilities
Reliability:
- Volume mounts for database persistence
- Health checks for all containers
- Automatic restarts on failure
- Multi-replica deployments
- Load balancing
Operations:
- Centralized logging (not
docker compose logs) - Monitoring and metrics (Prometheus + Grafana)
- CI/CD pipeline (Jenkins, GitHub Actions)
- Blue-green or rolling deployments
- Automated rollback on failure
Reflection: The Learning Process
What worked:
- Starting simple (single container) before adding complexity
- Understanding networking by breaking it first
- Simulating the full workflow (build → registry → deploy)
What I'd do differently:
- Read about Docker networking BEFORE starting (would have saved hours)
- Use Docker Compose from the beginning (not standalone containers)
- Set up logging earlier (debugging would have been easier)
Time investment: 6–8 hours over one day
Cost: $0 (everything ran locally, ECR free tier)
Breakdown:
- 1 hour: Docker installation and configuration
- 2 hours: Containerizing the Node.js app
- 2–3 hours: Debugging networking issues
- 1 hour: ECR setup and authentication
- 1 hour: Testing the complete workflow
How This Connects to Real DevOps Workflows
Right now, I'm doing everything manually:
- I change code
- I run
docker build - I run
docker push - I update docker-compose.yaml
- I run
docker compose up
In production with CI/CD:
- Developer pushes code to Git
- CI detects push, runs
docker buildautomatically - CI pushes image to ECR with version tag
- CD updates Kubernetes manifests or ECS task definitions
- Orchestrator pulls new image and rolls out deployment
- Orchestrator monitors health, rolls back if failures detected
Everything I did manually gets automated. But you can't automate what you don't understand, and that's why working through it manually first was valuable.
What's Next
With containers and registries working, the next phase is CI/CD automation. I'll be:
- Setting up Jenkins to automatically build and push images
- Implementing automated testing before deployment
- Learning pipeline-as-code (Jenkinsfile)
- Eventually deploying to Kubernetes (AWS EKS)
I'm expecting:
- Jenkins configuration headaches (plugins, credentials)
- Pipeline syntax errors
- Integration issues between GitHub, Jenkins, and AWS
- Some new failure modes I haven't encountered
But I'm also starting to see how all these pieces fit together: containers provide portability, registries provide storage, orchestrators provide scaling, and CI/CD ties it all together into an automated deployment pipeline.
Every project builds on the last. And every error teaches me something that documentation alone never could.