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
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.
Ubuntu 24.04 LTS 2 GB 27.x or newer 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.
Update the system and install dependencies:
sudo apt update
sudo apt install -y ca-certificates curl gnupg lsb-releaseThese dependencies are prerequisites to add the repository with a verified
GPG key — don’t skip this step, otherwise apt will refuse the repo.
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.gpgConfigure 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-pluginAdd your user to the docker group to avoid using sudo:
sudo usermod -aG docker $USER
newgrp docker
docker run --rm hello-worldThe 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.
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.
Create a .dockerignore to shrink the build context:
node_modules
npm-debug.log
.git
.env
.env.*
dist
coverage
*.mdWithout 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.
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.
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.
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.
Bring the whole stack up:
docker compose up -d --build
docker compose ps
docker compose logs -f api-nodeThe -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.
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.
Test the HTTP endpoints from the VPS:
curl -i http://localhost:3000/health
curl -i http://localhost:8000/healthEach 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>.
Validate that everything comes up on its own after reboot:
sudo rebootAfter 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 thatdepends_onwaits 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.