The Pivot: Why Containerization First?
After careful thought and consideration, I realized that building the VPC right away wasn't the most efficient path. Instead, I went back to the drawing board and decided to containerize the application first. This ensures that the environment is consistent before we start orchestrating it in the cloud.
In my previous post, I introduced the project, the file structure, and the "why" behind this build. In this post, I'll be walking through the containerization process.
The Strategy
Since the frontend and backend are two distinct applications with different tech stacks, I wrote two separate Dockerfiles that align with the project structure.
1. The Backend (Node.js)
For the backend, the setup is relatively straightforward.
FROM node:22-slim
WORKDIR /usr/src/backend/
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY . .
EXPOSE 5000
CMD ["node", "server.js"]
Breaking it down:
-
Base Image: I used
node:22-sliminstead of the common alpine version. This is because the backend usessqlite3, which requires dependencies likegcc,g++, andpythonto compile. The slim image handles these better than the ultra-minimal Alpine. -
Dependencies: I used
npm ci --omit=dev. This ignores development dependencies, keeping the production image as light as possible. -
Execution: We expose port 5000 and run the app using
node server.js.
2. The Frontend (React + Nginx)
The frontend utilizes a multi-stage build. This allows us to build the app in a Node environment and then serve the static files using Nginx.
# Stage 1: Build
FROM node:22-alpine AS builder
WORKDIR /usr/src/frontend/
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 2: Serve
FROM nginx:alpine AS server
COPY --from=builder /usr/src/frontend/dist/ /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
Why this approach?
- Lightweight: Nginx is significantly faster and smaller than keeping a Node server running just to serve static React files.
-
Reverse Proxy: Since React is client-side, it doesn't "know" how to route API requests to a backend in a different container. We use Nginx to handle these routes via a custom
nginx.conf. - CI/CD Friendly: Building inside Docker means your CI/CD runner (like GitHub Actions) doesn't need to have Node installed. Everything happens inside the container.
Orchestrating with Docker Compose
To manage the frontend, backend, and database simultaneously, I used Docker Compose.
name: note-taker
services:
backend:
build: ./src/backend
restart: unless-stopped
ports:
- "5001:5000"
environment:
NODE_ENV: production
PGHOST: database
PGUSER: postgres
PGPASSWORD: ${POSTGRES_PASSWORD}
PGDATABASE: notetaker
PGPORT: 5432
PGSSL: false
depends_on:
database:
condition: service_healthy
networks:
- app-network
database:
image: postgres:15-alpine
container_name: production_db
environment:
POSTGRES_DB: notetaker
POSTGRES_USER: postgres
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
ports:
- "5432:5432"
volumes:
- db-data:/var/lib/postgresql/data
networks:
- app-network
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d notetaker"]
interval: 5s
timeout: 3s
retries: 5
frontend:
build: ./src/frontend
ports:
- 3000:80
depends_on:
- backend
networks:
- app-network
volumes:
db-data:
networks:
app-network:
Key Features of this Compose file:
-
Health Checks: The backend waits for the database to be "healthy" (using
pg_isready) before starting. - Port Mapping: I mapped the backend to 5001 and the frontend to 3000 to avoid conflicts with other local services like XAMPP or Docker Desktop defaults.
-
Networking: All services share
app-network, allowing them to communicate via service names (e.g., the backend connects todatabase:5432).
Nginx Configuration
Without a custom nginx config, the frontend container would serve static files but have no idea where to send API requests. The browser makes requests to /api/notes and /health, but nginx doesn't know these belong to the backend.
server {
listen 80;
location /api/ {
proxy_pass http://backend:5000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /health {
proxy_pass http://backend:5000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
root /usr/share/nginx/html;
index index.html;
try_files $uri $uri/ /index.html;
}
}
The key pieces:
-
proxy_passforwards/api/and/healthrequests to the backend container using its service name (backend:5000). Docker's built-in DNS resolves the name across containers on the same network. -
try_fileshandles React's client-side routing. Without it, refreshing on a route like/noteswould return a 404 because nginx would look for a file that doesn't exist. This directive falls back toindex.htmland lets React handle the route.
Troubleshooting: The SSL Headache
While running the backend, I hit a snag. The logs showed a connection failure:
{"level":"error","message":"Failed to connect to PostgreSQL database: The server does not support SSL connections"}
The Cause
In my application code, I had logic that forced SSL if the environment was set to production:
ssl: process.env.PGSSL === 'true' || process.env.NODE_ENV === 'production'
Since the Docker Compose environment was set to NODE_ENV: production, the app was refusing any non-SSL connection from the Postgres container.
The Fix
I updated the logic in db.js to be more granular. This allows me to explicitly toggle SSL off via environment variables even in production-mode containers:
ssl: process.env.PGSSL === 'true' ? { rejectUnauthorized: false }
: process.env.PGSSL === 'false' ? false
: process.env.NODE_ENV === 'production' ? { rejectUnauthorized: false }
: false
By setting PGSSL: false in the Docker Compose file, the connection was finally successful.
What's Next?
Now that the application is fully containerized and running locally, the next step is to setup the IAM roles through a terraform script.
Have you ever run into issues where "Production" environment flags caused local Docker testing to fail? How do you handle your SSL logic in dev vs. prod? Let me know in the comments!
If you want to follow along on this project, here is my github repo
Top comments (0)