Module C-1·45 min read

Deploy a full stack (Next.js, Node, Postgres, Redis, Nginx) to a Linux VPS with production-ready security and operations.

Introduction

You have mastered container internals, optimized your multi-stage builds, secured your runtime environments, and configured robust observability.

Now it is time to put everything together.

In this final capstone module, we will architect a production-ready deployment for a full-stack SaaS application on a Linux Virtual Private Server (VPS).


The SaaS Architecture

We will deploy a complete modern stack:

  1. Nginx: A reverse proxy handling SSL termination and routing.
  2. Next.js: The frontend client.
  3. Node.js (Express): The backend API.
  4. PostgreSQL: The primary relational database.
  5. Redis: The session cache and job queue.

Deployment Constraints

This deployment must meet strict production criteria:

  • No root users running application code.
  • Minimal image sizes via multi-stage builds.
  • Isolated networking (the database cannot be accessible from the internet).
  • Guaranteed boot order via healthchecks.
  • Resource limits to prevent host crashes.
  • Secret management to avoid leaking database passwords.

Step 1: The Optimized Dockerfiles

You will maintain separate Dockerfiles for the Frontend and the Backend.

The Backend (Node.js API)

dockerfile
# api/Dockerfile # syntax=docker/dockerfile:1 # -- STAGE 1: Dependencies -- FROM node:22-alpine AS deps WORKDIR /app COPY package*.json ./ RUN --mount=type=cache,target=/root/.npm npm ci # -- STAGE 2: Builder -- FROM node:22-alpine AS builder WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . RUN npm run build # -- STAGE 3: Production Runner -- FROM node:22-alpine AS runner WORKDIR /app ENV NODE_ENV=production # Install dumb-init for proper signal handling (PID 1) RUN apk add --no-cache dumb-init # Copy minimal artifacts COPY --from=builder /app/dist ./dist COPY --from=builder /app/package.json ./package.json COPY --from=builder /app/node_modules ./node_modules # Security: Switch to non-root user USER node # Healthcheck: Fail if API cannot answer a ping HEALTHCHECK --interval=30s --timeout=3s --retries=3 \ CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1 ENTRYPOINT ["/usr/bin/dumb-init", "--"] CMD ["node", "dist/server.js"]

Step 2: The Production Compose File

We will orchestrate the entire deployment using a production-hardened compose.yaml.

yaml
# compose.yaml name: saas-production # Define a custom bridge network for internal DNS networks: saas_net: driver: bridge # Define persistent volumes for stateful data volumes: pgdata: redisdata: services: # ---------------------------------------- # 1. PostgreSQL Database # ---------------------------------------- db: image: postgres:15-alpine networks: - saas_net volumes: - pgdata:/var/lib/postgresql/data environment: POSTGRES_USER: admin POSTGRES_PASSWORD_FILE: /run/secrets/db_password POSTGRES_DB: saas_prod secrets: - db_password restart: unless-stopped deploy: resources: limits: memory: 1G healthcheck: test: ["CMD-SHELL", "pg_isready -U admin -d saas_prod"] interval: 10s timeout: 5s retries: 5 # ---------------------------------------- # 2. Redis Cache # ---------------------------------------- cache: image: redis:7-alpine networks: - saas_net volumes: - redisdata:/data command: redis-server --appendonly yes # Persist data to disk restart: unless-stopped deploy: resources: limits: memory: 256M # ---------------------------------------- # 3. Node.js API # ---------------------------------------- api: build: context: ./api networks: - saas_net # Note: No published ports! Traffic must flow through Nginx. environment: NODE_ENV: production DATABASE_URL: postgres://admin:${DB_PASS}@db:5432/saas_prod REDIS_URL: redis://cache:6379 depends_on: db: condition: service_healthy cache: condition: service_started restart: unless-stopped deploy: resources: limits: memory: 512M logging: driver: "json-file" options: max-size: "50m" max-file: "3" # ---------------------------------------- # 4. Nginx Reverse Proxy # ---------------------------------------- nginx: image: nginx:alpine networks: - saas_net ports: - "80:80" - "443:443" volumes: - ./nginx.conf:/etc/nginx/nginx.conf:ro # Read-only mount - ./certs:/etc/nginx/certs:ro depends_on: - api restart: unless-stopped # Define file-based secrets secrets: db_password: file: ./secrets/db_password.txt

Step 3: Analyzing the Architecture

Why did we build it this way?

1. Isolated Networking

Notice that the db and cache services do not have a ports directive. They are completely inaccessible from the public internet. The only way to talk to PostgreSQL is to be on the saas_net internal bridge network.

Similarly, the api service does not publish port 8080. Instead, Nginx binds to ports 80 and 443 on the host, accepts public internet traffic, handles SSL, and proxies the traffic internally over saas_net to the API container.

2. Log Management

We explicitly limited the Node.js API logs to max-size: "50m" with 3 rolling files. If the API gets hit by a massive spike in traffic, it will not fill up the Linux VPS hard drive and crash the server.

3. Graceful Boot Sequencing

By combining the pg_isready healthcheck with depends_on: condition: service_healthy, Compose guarantees that the Node.js API will not boot until the database is fully ready to accept queries.


Step 4: The Deployment Workflow

If you SSH into your Linux VPS, your deployment workflow is simple, repeatable, and completely declarative:

  1. Create a .env file containing the DB_PASS.
  2. Create the ./secrets/db_password.txt file.
  3. Run the deployment:
bash
docker compose up -d --build

Docker will build the minimal multi-stage image, create the internal networks, provision persistent volumes, boot the database, wait for it to become healthy, boot the API, and finally boot the Nginx proxy.


Conclusion

Congratulations. You have completed the Docker In-Depth curriculum.

You are no longer a developer who just memorizes docker run. You understand how the Linux kernel provides namespaces and cgroups. You know how OverlayFS layers dictate caching strategy. You understand the dangers of PID 1, the risks of running as root, and how to orchestrate complex services securely.

You are now equipped to engineer, secure, and operate Docker containers in high-stakes production environments.


Knowledge Check

Question 1: In the production compose.yaml file presented in this module, neither the db (PostgreSQL) nor the api (Node.js) services have a ports directive defined. How do external users interact with the application?

  • A) Docker automatically maps port 80 to the api service because it is a Node.js container.
  • B) External users interact directly with the nginx service (which publishes ports 80 and 443), and Nginx securely proxies the traffic internally over the saas_net custom bridge network to the api.
  • C) The api service binds to the host network directly, bypassing Docker's networking.
  • D) You must manually add a firewall rule to the host machine to forward traffic to the api container's internal IP.
Reveal Answer

Correct Answer: B

By omitting the ports directive, you keep the containers completely isolated from the host's public interfaces. The only entry point to the system is the Nginx reverse proxy, which binds to host ports 80/443, terminates SSL, and forwards requests internally to the API. This severely restricts the attack surface.


Question 2: In the provided multi-stage Dockerfile for the Node.js API, why is the dumb-init package installed via apk add?

  • A) It provides a lightweight web server to serve static files.
  • B) It acts as a proper PID 1 init system, ensuring that OS signals like SIGTERM are correctly forwarded to the Node.js process to allow for graceful shutdown.
  • C) It is required to drop root privileges and switch to the node user.
  • D) It automatically restarts the Node.js process if it crashes, acting like nodemon for production.
Reveal Answer

Correct Answer: B

Node.js was not designed to run as PID 1 in a Linux environment and does not handle standard init responsibilities, such as signal forwarding or reaping zombie processes. Using dumb-init as the ENTRYPOINT wraps the Node.js process, correctly handling Docker's SIGTERM signals and preventing abrupt SIGKILL terminations during deployments.


Question 3: What is the primary purpose of defining limits under the deploy.resources key for the db, cache, and api services in the production Compose file?

  • A) It guarantees that the containers are evenly scheduled across a Docker Swarm cluster.
  • B) It prevents a single compromised or buggy container from consuming 100% of the host machine's memory, which would trigger a host-level Out-of-Memory (OOM) crash that takes down all other services.
  • C) It allocates dedicated physical CPU cores to each container to prevent noisy neighbor problems.
  • D) It instructs Docker to automatically scale the containers horizontally if memory usage exceeds the limit.
Reveal Answer

Correct Answer: B

Without resource limits, any container can exhaust the underlying host's RAM, causing the kernel to panic and unpredictably kill vital system processes. Setting limits ensures that if a specific service experiences a memory leak (e.g., the API), only that specific container is OOM-killed and restarted, isolating the failure.

Discussion

0

Join the discussion

Loading comments...

© 2026 Jatin Jain Saraf (JJS). All rights reserved.