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:

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:

  1. Write a Dockerfile (15 minutes)
  2. Build an image (5 minutes)
  3. Run containers with Docker Compose (10 minutes)
  4. Push to AWS ECR (10 minutes)

What actually happened:

  1. Installed Docker, hit a heredoc syntax error
  2. Built an image, app couldn't connect to MongoDB
  3. Spent 2 hours debugging container networking
  4. Fixed networking, MongoExpress crashed in a loop
  5. Pushed to ECR, hit credential storage errors
  6. 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.

The Fix

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
Error
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.

Why This Matters

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:

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.

Lesson Learned

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 .
Success
[+] 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
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:

  1. Creates a network called <project>_default
  2. Connects all services to that network
  3. Sets up DNS so services can find each other by name
  4. Starts containers in dependency order (though it doesn't wait for them to be ready)

First Attempt

docker compose up
Output
[+] 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:

  1. Change code
  2. Rebuild image: docker build -t my-app:1.0 .
  3. Restart containers: docker compose down && docker compose up

I did this. Finally:

docker compose logs my-app
Success
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
Crash Loop
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"
Credential Types

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 credentials
  • ME_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:

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
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:

Success
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
Output
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:

  1. Docker Compose saw 601970634480.dkr.ecr.us-east-2.amazonaws.com/my-app:1.0
  2. Since it's not a Docker Hub image, Docker checked if I had it locally
  3. I didn't, so it pulled it from ECR (using my authenticated session)
  4. MongoDB and MongoExpress pulled from Docker Hub (public, no auth needed)
  5. All containers started

I accessed localhost:3000. The application worked.

This Simulated a Production Deployment
  • 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:

When you run in Docker:

Solution:

Port Mapping: Host vs Container

my-app:
  ports:
    - 3000:3000

What this means:

Container-to-container communication doesn't use these mappings:

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:

  1. Change code
  2. Rebuild image
  3. 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

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:

5. Docker Compose Is Not Production Orchestration

Docker Compose is great for development and simple deployments, but it has limitations:

Production uses:

Production Considerations (What This Demo Skipped)

What I Did (Learning)

What Production Would Add

Security:

Reliability:

Operations:

Reflection: The Learning Process

What worked:

What I'd do differently:

Time investment: 6–8 hours over one day

Cost: $0 (everything ran locally, ECR free tier)

Breakdown:

How This Connects to Real DevOps Workflows

Right now, I'm doing everything manually:

  1. I change code
  2. I run docker build
  3. I run docker push
  4. I update docker-compose.yaml
  5. I run docker compose up

In production with CI/CD:

  1. Developer pushes code to Git
  2. CI detects push, runs docker build automatically
  3. CI pushes image to ECR with version tag
  4. CD updates Kubernetes manifests or ECS task definitions
  5. Orchestrator pulls new image and rolls out deployment
  6. 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:

I'm expecting:

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.