As a Cloud and DevOps Engineer, one of the most satisfying milestones is taking an application that "works on my machine" and transforming it into a robust, portable, and scalable architecture.
Recently, I decided to containerize an Infrastructure Asset Tracker application I’ve been developing. The goal wasn't just to wrap the Node.js backend in a Docker container, but to orchestrate a complete 3-tier architecture (Backend, Database, and Admin Visualizer) using Docker Compose, and securely host the final image in AWS Elastic Container Registry (ECR).
Here is a deep dive into the architecture, the optimizations I made, and the real-world debugging it took to get everything running seamlessly.
The Architecture Setup
The stack consists of three independent microservices communicating over an internal Docker network:
-
The Backend (
devops-app:v1): A custom Node.js REST API. -
The Database (
mongodb): The official MongoDB image. -
The Visualizer (
mongo-express): A web-based admin interface for MongoDB.
Instead of building a "Frankenstein" container (shoving the database and the app into one massive image), I strictly adhered to the one-concern-per-container rule. This ensures each layer can scale independently and fail gracefully.
Phase 1: Crafting an Optimized Dockerfile
Writing a Dockerfile is easy. Writing an optimized one takes deliberate engineering.
1. The Base Image & Shell Gotchas
I opted for FROM node:18-alpine. Alpine Linux is incredibly lightweight and highly secure, keeping the final image size down to just ~51MB. However, this comes with a classic DevOps gotcha: Alpine strips out heavier utilities like bash. When I needed to shell into the running container to verify my file structures, docker exec -it <container> /bin/bash failed. I had to pivot to using the native /bin/sh.
2. Leveraging Layer Caching
To make my builds lightning fast, I separated the dependency installation from the source code copy:
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
Because Docker caches layers sequentially, copying only the package.json first protects the heavy npm install step. Now, if I change a single line of JavaScript or HTML, Docker uses the cached node modules and updates the code layer in less than a second.
3. The .dockerignore File
Crucially, I added a .dockerignore file containing node_modules and .env. This prevents host-OS compiled C++ bindings from breaking the Alpine container and ensures my local database passwords never accidentally get baked into the production image.
Phase 2: Orchestration & Persistence with Docker Compose
To wire the three containers together, I built a docker-compose.yml (named mongo.yaml) master blueprint. Two specific configurations were critical here:
Solving the Startup Race Condition
If the Node.js backend or Mongo Express boots up faster than the MongoDB database, they will throw connection timeouts and crash. To prevent this, I utilized depends_on:
depends_on:
- mongodb
This acts as a traffic cop, explicitly forcing the backend and visualizer to wait until the database container is fully initialized before attempting to connect.
Ensuring Data Persistence
Docker containers are ephemeral if you spin them down, the data inside dies with them. To ensure the infrastructure assets logged in my app survived a restart, I implemented a named Docker Volume:
volumes:
- mongodb_data:/data/db
This maps a secure, permanent directory on the host machine directly into MongoDB’s internal storage path.
Phase 3: Real-World Debugging
No deployment goes perfectly on the first try. Here are two specific hurdles I had to debug during the orchestration phase:
-
Port Collisions (
ERR_CONNECTION_REFUSED): During deployment, my Node.js container failed to bind to port 3000. Runningdocker psrevealed the container was isolated (3000/tcp) instead of mapped (0.0.0.0:3000->3000/tcp). The culprit? A ghost Node.js process still running on my local host. A quickpkill nodecleared the parking space, and a freshdocker compose up -dsuccessfully mapped the port. -
The Disconnected Daemon: I encountered a massive red error:
failed to connect to the docker API at unix:///home/.../docker.sock. This was a context mismatch between my terminal and the Docker engine. Ensuring the Docker background service was awake and resetting the terminal context resolved the socket connection.
Phase 4: Pushing to AWS ECR
With the architecture running flawlessly locally, I tagged my custom image and pushed it to a private AWS ECR repository.
A fascinating detail about modern Docker (using the BuildKit engine) is how it handles the push. In the AWS console, I didn't just see one file; I saw three:
-
The Image Index (Manifest List): The master pointer for the
v1tag. - The Application Image: The actual 51MB Node.js container.
- The Attestation: A cryptographic receipt proving exactly how the image was built and what dependencies were used a massive win for modern supply-chain security.
The Takeaway
Containerizing this architecture solidified my mental model of how host machines, isolated networks, and storage layers interact. We've officially moved past "it works on my machine" to "it works exactly as designed, anywhere."
Next on the roadmap: Automating this deployment flow with a complete CI/CD pipeline!
Top comments (0)