How to run Node.js and Python in Docker on a VPS: practical guide

Learn how to run Node.js and Python in Docker on a Linux VPS: Dockerfile, docker compose, volumes, isolated networks and reproducible deploys.

Running Node.js and Python applications in Docker on a VPS solves three problems that show up in any non-trivial deploy: system dependencies fighting between projects, runtime versions locked to the host, and environments that “work on my machine” but blow up in production. With Docker, each application carries its own version of Node, Python, system libraries and configuration — isolated in reproducible containers.

This guide covers the full workflow: installing Docker and Docker Compose on the VPS, writing lean Dockerfiles for Node.js (Express/Fastify) and Python (FastAPI/Flask), orchestrating with docker compose, defining isolated networks for service-to-service communication, persisting data with volumes and validating that everything comes up automatically after a reboot. The focus is on real production patterns — not “hello world” examples that don’t scale.

Estimated execution time: 35-45 minutes to read, apply and validate on a clean VPS with Ubuntu 24.04 LTS. If you already have Docker installed, jump straight to the Dockerfiles section.

Prerequisites

Before you start

You need a Linux VPS with Ubuntu 24.04 LTS (or Debian 12+), SSH access as a user with sudo, and at least 2 GB of free RAM to run Node and Python in parallel. You also need the application code versioned in Git (GitHub, GitLab, or a private repo) — we’ll clone it inside the image build.

System Ubuntu 24.04 LTS
Minimum RAM 2 GB
Docker 27.x or newer
Docker Compose v2 (official plugin)

Installing Docker Engine on the VPS

The default install via apt install docker.io ships an outdated version from Ubuntu Universe. The correct path is to use Docker’s official repository, which delivers current releases and the docker compose v2 plugin integrated.

01

Update the system and install dependencies:

sudo apt update
sudo apt install -y ca-certificates curl gnupg lsb-release

These dependencies are prerequisites to add the repository with a verified GPG key — don’t skip this step, otherwise apt will refuse the repo.

02

Add Docker’s official GPG key:

sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | \
  sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
sudo chmod a+r /etc/apt/keyrings/docker.gpg
03

Configure the repository and install Docker Engine + Compose plugin:

echo "deb [arch=$(dpkg --print-architecture) \
  signed-by=/etc/apt/keyrings/docker.gpg] \
  https://download.docker.com/linux/ubuntu \
  $(lsb_release -cs) stable" | \
  sudo tee /etc/apt/sources.list.d/docker.list > /dev/null

sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io \
  docker-buildx-plugin docker-compose-plugin
04

Add your user to the docker group to avoid using sudo:

sudo usermod -aG docker $USER
newgrp docker
docker run --rm hello-world

The hello-world confirms the daemon is running and your user has permission. If you see “Hello from Docker!”, you’re good.

Dockerfile for Node.js applications

For Node.js, the recommended pattern is a multi-stage build: one stage installs dependencies and compiles (if you’re using TypeScript), another final stage only loads the runtime and the prebuilt artifacts. The result: a final image 5-10x smaller.

05

In the root directory of your Node project, create a Dockerfile:

# Stage 1: build
FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .

# Stage 2: runtime
FROM node:22-alpine
WORKDIR /app
RUN addgroup -g 1001 -S nodejs && \
    adduser -S nodeapp -u 1001
COPY --from=builder --chown=nodeapp:nodejs /app /app
USER nodeapp
EXPOSE 3000
CMD ["node", "server.js"]

The node:22-alpine image weighs ~45 MB versus ~380 MB for node:22. The non-root user (nodeapp) is defense-in-depth — if the application is compromised, the attacker doesn’t have root inside the container.

06

Create a .dockerignore to shrink the build context:

node_modules
npm-debug.log
.git
.env
.env.*
dist
coverage
*.md

Without this, Docker copies your local node_modules (which can be 500 MB+) into the build context, making the build slow and the image bloated.

Dockerfile for Python applications

For Python, the pattern is similar: slim base image, isolated virtualenv or direct install, and non-root user. FastAPI with Uvicorn is the modern example; for Flask or Django the skeleton is identical, only the CMD changes.

07

Create the Dockerfile in the Python project directory:

FROM python:3.12-slim AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir --user -r requirements.txt

FROM python:3.12-slim
WORKDIR /app
RUN useradd -m -u 1001 pyapp
COPY --from=builder /root/.local /home/pyapp/.local
COPY --chown=pyapp:pyapp . .
USER pyapp
ENV PATH=/home/pyapp/.local/bin:$PATH
ENV PYTHONUNBUFFERED=1
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

The PYTHONUNBUFFERED=1 flag forces stdout/stderr to flush without buffering — without it, logs stay stuck in memory and only show up when the process dies, which makes debugging in production hard.

Don't use python:3.12 without a suffix

The python:3.12 tag pulls the full image at 1 GB+. Use python:3.12-slim (150 MB) or python:3.12-alpine (50 MB). Alpine sometimes breaks packages that depend on glibc (numpy, pandas with C extensions) — in that case, stick with slim.

Orchestrating with Docker Compose

In real production, you rarely run a single container. The typical scenario is Node + Python + database + Redis, all coordinated. docker compose solves this with a declarative file.

08

At the root of the directory containing both applications, create docker-compose.yml:

services:
  api-node:
    build: ./node-app
    container_name: api-node
    restart: unless-stopped
    networks:
      - backend
    ports:
      - "3000:3000"
    environment:
      - NODE_ENV=production
      - DATABASE_URL=postgres://app:secret@db:5432/appdb
    depends_on:
      - db

  api-python:
    build: ./python-app
    container_name: api-python
    restart: unless-stopped
    networks:
      - backend
    ports:
      - "8000:8000"
    environment:
      - DATABASE_URL=postgresql://app:secret@db:5432/appdb
    depends_on:
      - db

  db:
    image: postgres:16-alpine
    container_name: postgres-db
    restart: unless-stopped
    networks:
      - backend
    environment:
      - POSTGRES_USER=app
      - POSTGRES_PASSWORD=secret
      - POSTGRES_DB=appdb
    volumes:
      - postgres-data:/var/lib/postgresql/data

networks:
  backend:
    driver: bridge

volumes:
  postgres-data:

Both applications share the backend network and reach the database by the name db (Docker’s internal DNS). Named volumes persist database data between docker compose down and up.

09

Bring the whole stack up:

docker compose up -d --build
docker compose ps
docker compose logs -f api-node

The -d flag runs in the background, --build rebuilds images if the Dockerfiles changed. logs -f follows the output in real time — Ctrl+C exits the follow without stopping the container.

Plaintext passwords in compose

The docker-compose.yml above has the password hardcoded to keep things simple. In production, use an .env file next to the compose with variables (e.g. POSTGRES_PASSWORD=...) and reference them as ${POSTGRES_PASSWORD}. Add .env to .gitignore.

Verification

Confirm everything came up and is reachable.

10

Test the HTTP endpoints from the VPS:

curl -i http://localhost:3000/health
curl -i http://localhost:8000/health

Each should return HTTP/1.1 200 OK with your application’s payload. If it returns Connection refused, the container isn’t listening on the expected port — check docker compose logs <service>.

11

Validate that everything comes up on its own after reboot:

sudo reboot

After reconnecting via SSH (wait 30-60s), run docker compose ps. The containers should be Up because of restart: unless-stopped. If not, the docker.service may not be enabled at boot — fix it with sudo systemctl enable docker.

Troubleshooting

”Cannot connect to the Docker daemon”

This means your user isn’t in the docker group or the SSH session hasn’t reloaded the group. Log out and back in over SSH, or run newgrp docker.

Container restarts in a loop (CrashLoopBackOff equivalent)

Run docker compose logs <service> to see the real error. Most common causes: missing environment variable, database not ready when the app tries to connect (use depends_on + healthcheck), or port already in use on the host. sudo ss -tlnp | grep <port> shows who’s using it.

Slow build or disk full

Old images and stopped containers pile up. Clean them with docker system prune -a --volumes. Careful: this removes EVERYTHING not in use, including unreferenced volumes.

Next steps

With Docker working, three areas are worth diving deeper into:

  • Reverse proxy with TLS: put Caddy or Nginx in front of the containers for automatic HTTPS with Let’s Encrypt and a custom domain.
  • Health checks in compose: add healthcheck: to your services so that depends_on waits for the database to be ready, not just “started”.
  • CI/CD with GitHub Actions: automate build + push of the image to Docker Hub or GHCR and deploy via SSH on the VPS.

If you’re pushing this to production, a Hostini VPS ships with an updated kernel and native cgroups v2 support — which avoids incompatibilities with recent Docker versions and improves resource isolation between containers.

Frequently asked questions

Can I run Node.js and Python in the same container?

Technically yes, but it's an anti-pattern. Each container should have a single main process (PID 1). The right approach is two separate containers on the same Docker network, communicating via internal DNS (the service name resolves to the container IP).

What's the difference between COPY and ADD in a Dockerfile?

COPY copies local files into the image — it's what you almost always want. ADD does the same thing but also accepts HTTP URLs and automatically extracts .tar archives. For predictability, use COPY unless you explicitly need auto-extract.

Why can't my Node app reach localhost:5432 of Postgres in another container?

Inside the container, 'localhost' points to the container itself, not the host. Use the service name defined in docker-compose.yml (e.g. postgres:5432) or the container name. They resolve via Docker's internal DNS.

How do I hot-reload code without rebuilding the image?

Mount the code directory as a bind volume in docker-compose.yml (e.g. ./src:/app/src) and use nodemon (Node) or uvicorn --reload (Python). In production this is not recommended — only for local development.

Do I need root to install packages inside the container?

Not in production. Create a non-root user in the Dockerfile (USER node or USER 1001) and run the process as that user. If a package requires root to install, do it before the USER directive — install as root, then switch to the non-privileged user for runtime.

Container logs are filling the disk. How do I fix it?

Configure log rotation in /etc/docker/daemon.json with the json-file driver, max-size and max-file. Example: max-size=10m, max-file=3 limits 30 MB per container. Restart the Docker daemon and new containers will respect the limit.

Topics:
Next steps Ryzen cloud with NVMe storage and always-on DDoS protection.Go live on a Hostini VPS →
Was this tutorial helpful?
Chat on WhatsApp