For JavaScript Developers: Comparing Node.js and Python Container Setups

Week 1, Wednesday - Afternoon Session

Lecture Overview

As a JavaScript developer, you're likely familiar with containerizing Node.js applications. In this session, we'll explore how Python container setups compare to Node.js, highlighting similarities and differences to help you transition smoothly. We'll cover environment setup, dependency management, development workflows, and production considerations for both ecosystems.

Base Image Selection

One of the first decisions when containerizing an application is choosing a base image. Let's compare the options available for Node.js and Python.

Node.js Base Images

Node.js official images on Docker Hub offer several variants:

Python Base Images

Python official images follow a similar pattern:

Variant Node.js Python Best Used For
Full node:18 python:3.9 Complex build requirements, native extensions
Slim node:18-slim python:3.9-slim Balance of size and compatibility
Alpine node:18-alpine python:3.9-alpine Minimal size, simple applications

Note for JS developers: Just like in the Node.js world where Alpine images can cause issues with native modules (like bcrypt or node-sass), Python Alpine images can have problems with certain packages that require compilation (like numpy or pandas). When in doubt, start with the slim variant.

Example Dockerfile Comparison

Here's a basic comparison of Dockerfiles for a simple web application:

Node.js Dockerfile:
FROM node:18-slim

WORKDIR /app

COPY package*.json ./
RUN npm install

COPY . .

EXPOSE 3000
CMD ["node", "server.js"]
Python Dockerfile:
FROM python:3.9-slim

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

EXPOSE 5000
CMD ["python", "app.py"]

Analogy: Think of base images like foundations for a house. Full images are like foundations with a full workshop and all possible tools included. Slim images include just the essential construction tools, while Alpine images are minimalist foundations with only the bare necessities. The choice depends on what you're building and what trade-offs you're willing to make between size and convenience.

Dependency Management

The most significant difference you'll notice when moving from Node.js to Python is how dependencies are managed.

Node.js Dependency Management

In Node.js, dependencies are managed using:

Python Dependency Management

Python typically uses:

Node.js Python Purpose
package.json requirements.txt Listing dependencies
package-lock.json Pipfile.lock / poetry.lock Locking exact versions
npm install pip install -r requirements.txt Installing dependencies
node_modules/ venv/ or .venv/ (virtual environment) Where dependencies are stored
npm scripts No direct equivalent (often custom scripts) Running common tasks

Dockerized Dependency Installation

The approach to installing dependencies in containers is similar but with some key differences:

Node.js dependency installation in Dockerfile:
# Copy dependency files
COPY package*.json ./

# Install dependencies
RUN npm ci  # For production (uses package-lock.json)
# or
RUN npm install  # For development
Python dependency installation in Dockerfile:
# Copy dependency files
COPY requirements.txt .

# Install dependencies
RUN pip install --no-cache-dir -r requirements.txt

Best practice: In both Node.js and Python containers, copy only the dependency definition files first, before copying the rest of your code. This leverages Docker's layer caching to avoid reinstalling dependencies when only your application code changes.

Note for JS developers: Unlike Node.js, Python doesn't have a built-in concept of "development" vs "production" dependencies. You'll typically manage this with separate requirements files (e.g., requirements.txt and requirements-dev.txt) or by using tools like Pipenv or Poetry that support this distinction.

Application Structure and Workflow

The application structure and development workflow differ between Node.js and Python environments.

Application Entry Points

Node.js Python
  • Defined in package.json's "main" field
  • Typically server.js or index.js
  • Started with node server.js
  • No standard entry point
  • Often app.py, main.py, or run.py
  • Started with python app.py

Development Servers

Node.js Python
  • nodemon for auto-reloading
  • Express for API servers
  • Next.js/Nuxt.js dev servers
  • Flask's development server
  • Django's runserver
  • Uvicorn/Hypercorn for ASGI apps

Hot Reloading in Containers

For development workflows, you'll want code changes to be reflected immediately in your containers:

Node.js development container:
# Dockerfile
FROM node:18-slim

WORKDIR /app

COPY package*.json ./
RUN npm install

# Don't copy code - will be mounted
EXPOSE 3000

# Use nodemon for hot reloading
CMD ["npx", "nodemon", "server.js"]
Running with mounted code:
docker run -it --rm -v $(pwd):/app -p 3000:3000 node-app
Python development container:
# Dockerfile
FROM python:3.9-slim

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# Don't copy code - will be mounted
EXPOSE 5000

# Flask development server with hot reload
CMD ["flask", "run", "--host=0.0.0.0"]
Running with mounted code:
docker run -it --rm -v $(pwd):/app -p 5000:5000 -e FLASK_APP=app.py -e FLASK_ENV=development python-app

Note for JS developers: Python doesn't have a direct equivalent to npm scripts. While you might be used to defining scripts in package.json, Python projects typically use Makefiles, shell scripts, or tools like invoke or tox for task automation.

Environment Variables and Configuration

Both Node.js and Python applications commonly use environment variables for configuration, especially in containerized environments.

Environment Variables Usage

Node.js environment variables:
// Accessing environment variables
const port = process.env.PORT || 3000;
const dbUrl = process.env.DATABASE_URL;
const nodeEnv = process.env.NODE_ENV || 'development';
Python environment variables:
import os

# Accessing environment variables
port = os.environ.get('PORT', 5000)
db_url = os.environ.get('DATABASE_URL')
flask_env = os.environ.get('FLASK_ENV', 'development')

Environment Variables in Dockerfile

Setting environment variables in Dockerfiles is identical for both:

# Setting environment variables
ENV NODE_ENV=production PORT=3000
# or
ENV FLASK_ENV=production PORT=5000

Configuration Libraries Comparison

Node.js Python Purpose
dotenv python-dotenv Loading .env files
config configparser Hierarchical configuration
convict pydantic Configuration validation

Example: Using dotenv in both ecosystems

Node.js dotenv:
// Load environment variables from .env file
require('dotenv').config();

const port = process.env.PORT || 3000;
Python dotenv:
from dotenv import load_dotenv
import os

# Load environment variables from .env file
load_dotenv()

port = os.environ.get('PORT', 5000)

Note for JS developers: Both ecosystems follow similar patterns for environment-based configuration, making this aspect of the transition relatively smooth. The main difference is how variables are accessed (process.env in Node.js vs os.environ in Python).

Multi-Stage Builds

Multi-stage builds are a powerful Docker feature used in both Node.js and Python applications to create smaller, more secure production images.

Node.js Multi-Stage Build

# Build stage
FROM node:18 AS build

WORKDIR /app

COPY package*.json ./
RUN npm ci

COPY . .

# For applications with a build step
RUN npm run build

# Production stage
FROM node:18-slim

WORKDIR /app

COPY --from=build /app/package*.json ./
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist

EXPOSE 3000
CMD ["node", "dist/server.js"]

Python Multi-Stage Build

# Build stage
FROM python:3.9 AS build

WORKDIR /app

COPY requirements.txt .
RUN pip wheel --no-cache-dir --no-deps --wheel-dir /app/wheels -r requirements.txt

# Production stage
FROM python:3.9-slim

WORKDIR /app

COPY --from=build /app/wheels /wheels
COPY --from=build /app/requirements.txt .

RUN pip install --no-cache --no-index --find-links=/wheels -r requirements.txt && \
    rm -rf /wheels

COPY . .

EXPOSE 5000
CMD ["gunicorn", "-b", "0.0.0.0:5000", "app:app"]

What's happening in the Python example:

  1. The first stage uses the full Python image to build wheel files (pre-built packages)
  2. The second stage uses the slim Python image and copies only the wheel files
  3. Dependencies are installed from the wheels rather than from PyPI
  4. Application code is copied to the production image
  5. The application is run using Gunicorn (a production WSGI server)

Note for JS developers: While the details differ, the multi-stage build pattern is conceptually identical between Node.js and Python. The key difference is what constitutes the "build" step—in Node.js it's often compiling TypeScript or bundling for production, while in Python it might involve pre-building wheels or compiling C extensions.

Production Server Considerations

A key difference between Node.js and Python in containerized environments is how production servers are typically set up.

Node.js Production Servers

In Node.js applications:

Python Production Servers

Python web applications typically use a multi-component setup:

Component Node.js Example Python Example
Application Server Node.js with Express Flask/Django with Gunicorn
Concurrency Model Event loop (single-threaded) Multiple worker processes
Process Management PM2 or Docker restart policies Gunicorn master or Docker restart policies
Typical Dockerfile CMD CMD ["node", "server.js"] CMD ["gunicorn", "-w", "4", "-b", "0.0.0.0:5000", "app:app"]

Example: Production-ready Python container with Gunicorn

FROM python:3.9-slim

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt gunicorn

COPY . .

EXPOSE 5000

# Run with 4 worker processes
CMD ["gunicorn", "--workers=4", "--bind=0.0.0.0:5000", "app:app"]

Note for JS developers: The biggest adjustment when moving from Node.js to Python for web applications is understanding that the development server (like Flask's built-in server) is never used in production. Instead, a dedicated WSGI/ASGI server runs your application. This is conceptually similar to how you might use nodemon in development but Node.js directly in production.

Development Workflows: Side-by-Side Comparison

Let's compare complete development workflows for containerized Node.js and Python applications.

Node.js Express Application

Project structure:

.
├── Dockerfile
├── docker-compose.yml
├── package.json
├── package-lock.json
├── src/
│   ├── server.js
│   ├── routes/
│   └── controllers/
└── .dockerignore

package.json:

{
  "name": "node-app",
  "version": "1.0.0",
  "main": "src/server.js",
  "scripts": {
    "start": "node src/server.js",
    "dev": "nodemon src/server.js"
  },
  "dependencies": {
    "express": "^4.17.1"
  },
  "devDependencies": {
    "nodemon": "^2.0.15"
  }
}

Dockerfile:

FROM node:18-slim

WORKDIR /app

COPY package*.json ./
RUN npm install

COPY . .

EXPOSE 3000

CMD ["npm", "run", "dev"]

docker-compose.yml:

version: '3'

services:
  app:
    build: .
    ports:
      - "3000:3000"
    volumes:
      - ./src:/app/src
    environment:
      - NODE_ENV=development

Python Flask Application

Project structure:

.
├── Dockerfile
├── docker-compose.yml
├── requirements.txt
├── app.py
├── templates/
├── static/
└── .dockerignore

requirements.txt:

flask==2.0.1
python-dotenv==0.19.0

Dockerfile:

FROM python:3.9-slim

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

EXPOSE 5000

ENV FLASK_APP=app.py
ENV FLASK_ENV=development

CMD ["flask", "run", "--host=0.0.0.0"]

docker-compose.yml:

version: '3'

services:
  app:
    build: .
    ports:
      - "5000:5000"
    volumes:
      - .:/app
    environment:
      - FLASK_APP=app.py
      - FLASK_ENV=development

Best practice: Use Docker Compose for development in both ecosystems. It simplifies managing volumes for hot reloading, environment variables, and connections to other services like databases.

Handling Database Connections

Connecting to databases from containerized applications follows similar patterns in both ecosystems.

Connecting to Databases

Node.js with PostgreSQL:
// Using node-postgres
const { Pool } = require('pg');

const pool = new Pool({
  host: process.env.DB_HOST || 'localhost',
  port: process.env.DB_PORT || 5432,
  database: process.env.DB_NAME || 'myapp',
  user: process.env.DB_USER || 'postgres',
  password: process.env.DB_PASSWORD || 'postgres'
});

async function query(text, params) {
  const result = await pool.query(text, params);
  return result.rows;
}
Python with PostgreSQL:
# Using psycopg2
import os
import psycopg2

def get_db_connection():
    return psycopg2.connect(
        host=os.environ.get('DB_HOST', 'localhost'),
        port=os.environ.get('DB_PORT', 5432),
        database=os.environ.get('DB_NAME', 'myapp'),
        user=os.environ.get('DB_USER', 'postgres'),
        password=os.environ.get('DB_PASSWORD', 'postgres')
    )

def query(text, params=None):
    conn = get_db_connection()
    cur = conn.cursor()
    cur.execute(text, params or ())
    rows = cur.fetchall()
    cur.close()
    conn.close()
    return rows

Using Docker Compose for Local Development with Databases

The Docker Compose setup is nearly identical for both ecosystems:

version: '3'

services:
  app:
    build: .
    ports:
      - "5000:5000"  # or 3000:3000 for Node.js
    volumes:
      - .:/app
    environment:
      - DB_HOST=db
      - DB_USER=postgres
      - DB_PASSWORD=postgres
      - DB_NAME=myapp
    depends_on:
      - db
      
  db:
    image: postgres:13-alpine
    environment:
      - POSTGRES_USER=postgres
      - POSTGRES_PASSWORD=postgres
      - POSTGRES_DB=myapp
    volumes:
      - postgres_data:/var/lib/postgresql/data
      
volumes:
  postgres_data:

Note for JS developers: While the database connection code looks different, the patterns are similar - both use connection pools, parameterized queries, and environment variables for configuration. The key difference is that Python doesn't have as mature an ecosystem of ORMs comparable to Sequelize or TypeORM (though SQLAlchemy and Django ORM are powerful alternatives).

Common Pitfalls and Solutions

When transitioning from Node.js to Python containers, be aware of these common issues:

File Permissions

Python applications may handle file permissions differently than Node.js applications:

Pitfall: Files created inside a Python container might be owned by root, causing permission issues when mounted to the host.

Solution: Create a non-root user in your Dockerfile and set appropriate permissions:

# Create a non-root user
RUN adduser --disabled-password --gecos "" appuser
USER appuser

# Or when working with specific directories
RUN mkdir -p /app/data && chown -R appuser:appuser /app/data

PYTHONPATH and Import Issues

Python's import system can be confusing for JavaScript developers:

Pitfall: Python imports might not work the same way in containers as they do locally.

Solution: Set the PYTHONPATH environment variable or use proper package structure:

# In Dockerfile
ENV PYTHONPATH=/app

# Or in docker-compose.yml
environment:
  - PYTHONPATH=/app

Hot Reloading Differences

Hot reloading works differently in Python:

Pitfall: Python development servers may not detect all file changes.

Solution: For Flask, ensure FLASK_ENV=development is set. For other frameworks, you might need specific tools:

# For Flask
ENV FLASK_ENV=development

# For Django
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]

# For FastAPI with uvicorn
CMD ["uvicorn", "main:app", "--reload", "--host", "0.0.0.0"]

Process Management

Python web servers handle processes differently than Node.js:

Pitfall: Running Python web applications directly without proper process management.

Solution: Use appropriate WSGI/ASGI servers:

# For Flask/Django
CMD ["gunicorn", "--workers=4", "--bind=0.0.0.0:5000", "app:app"]

# For ASGI applications (FastAPI, Starlette)
CMD ["uvicorn", "main:app", "--workers", "4", "--host", "0.0.0.0"]

Complete Examples: Side by Side

Let's look at complete, production-ready setups for both ecosystems.

Node.js Express Application

Dockerfile:

# Build stage
FROM node:18 AS build

WORKDIR /app

COPY package*.json ./
RUN npm ci

COPY . .

# Production stage
FROM node:18-slim

WORKDIR /app

COPY --from=build /app/package*.json ./
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/src ./src

# Create a non-root user
RUN adduser --disabled-password --gecos "" nodejs
USER nodejs

EXPOSE 3000

# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s \
  CMD node -e "const http = require('http'); const options = { hostname: 'localhost', port: 3000, path: '/health', timeout: 2000 }; const req = http.get(options, (res) => { process.exit(res.statusCode === 200 ? 0 : 1); }); req.on('error', () => process.exit(1)); req.end()"

# Production command
CMD ["node", "src/server.js"]

docker-compose.production.yml:

version: '3'

services:
  app:
    image: my-node-app:1.0
    restart: always
    ports:
      - "3000:3000"
    environment:
      - NODE_ENV=production
      - DB_HOST=db
    depends_on:
      - db
    healthcheck:
      test: ["CMD", "node", "-e", "require('http').get('http://localhost:3000/health', (r) => process.exit(r.statusCode === 200 ? 0 : 1))"]
      interval: 30s
      timeout: 3s
      retries: 3
      start_period: 5s
      
  db:
    image: postgres:13-alpine
    restart: always
    volumes:
      - postgres_data:/var/lib/postgresql/data
    environment:
      - POSTGRES_PASSWORD=production_password
      
volumes:
  postgres_data:

Python Flask Application

Dockerfile:

# Build stage
FROM python:3.9 AS build

WORKDIR /app

COPY requirements.txt .
RUN pip wheel --no-cache-dir --no-deps --wheel-dir /app/wheels -r requirements.txt

# Production stage
FROM python:3.9-slim

WORKDIR /app

COPY --from=build /app/wheels /wheels
COPY --from=build /app/requirements.txt .

RUN pip install --no-cache --no-index --find-links=/wheels -r requirements.txt && \
    rm -rf /wheels

COPY . .

# Create a non-root user
RUN adduser --disabled-password --gecos "" pyuser
USER pyuser

EXPOSE 5000

# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s \
  CMD curl --fail http://localhost:5000/health || exit 1

# Production command with Gunicorn
CMD ["gunicorn", "--workers=4", "--bind=0.0.0.0:5000", "app:app"]

docker-compose.production.yml:

version: '3'

services:
  app:
    image: my-flask-app:1.0
    restart: always
    ports:
      - "5000:5000"
    environment:
      - FLASK_ENV=production
      - DB_HOST=db
    depends_on:
      - db
    healthcheck:
      test: ["CMD", "curl", "--fail", "http://localhost:5000/health"]
      interval: 30s
      timeout: 3s
      retries: 3
      start_period: 5s
      
  db:
    image: postgres:13-alpine
    restart: always
    volumes:
      - postgres_data:/var/lib/postgresql/data
    environment:
      - POSTGRES_PASSWORD=production_password
      
volumes:
  postgres_data:

Best practice: Regardless of whether you're using Node.js or Python, the core Docker best practices remain the same:

  • Use multi-stage builds for smaller production images
  • Run as a non-root user for security
  • Include health checks for monitoring container health
  • Use appropriate process management for production
  • Set proper restart policies for resilience

Transition Guide for JavaScript Developers

Here are some tips to help you transition from Node.js to Python containers:

Conceptual Mapping

Node.js Concept Python Equivalent
npm/yarn pip, pipenv, or poetry
package.json requirements.txt or setup.py
Express Flask or Django
Nodemon Flask debug mode or watchdog
Jest/Mocha pytest or unittest
ESLint pylint or flake8
PM2 Gunicorn or Supervisor
Sequelize/TypeORM SQLAlchemy or Django ORM

Recommended Approach

  1. Start with Flask: It's more Express-like than Django and easier to learn
  2. Use Docker Compose for development: It simplifies environment setup
  3. Begin with simple applications: Avoid complex dependencies initially
  4. Learn Python's package structure: It's different from Node.js's module system
  5. Start with requirements.txt: Before moving to more complex tools like Poetry

Key Python Libraries for Node.js Developers

Key Takeaways

With this knowledge, you're well-equipped to transfer your Node.js container expertise to Python projects!

Looking Ahead

In our next session, we'll explore Docker Compose in more detail and see how it can help manage complex application setups with multiple services. We'll build on the knowledge from this comparison to develop a multi-container application using Python.

Discussion Questions

  1. How does Python's approach to dependency management compare to Node.js's? What advantages or disadvantages do you see in each approach?
  2. Why might Python's production server setup (with WSGI/ASGI servers) be beneficial compared to Node.js's direct execution model?
  3. How would you adapt your existing Node.js containerization practices when working with Python applications?
  4. What challenges might a team face when maintaining both Node.js and Python containers in the same project?
  5. How do the different concurrency models (Node.js event loop vs. Python's process-based concurrency) affect container configuration and resource allocation?

Additional Resources