Deploying a Node or Python app
This guide shows you how to deploy Node or Python applications to a Linux VPS using Docker and Docker Compose. You will write small, version-pinned Dockerfiles, manage environment variables and secrets safely, connect to a database container, and ensure your app restarts after a server reboot.
Deploying a Node or Python app
You deploy a Node or Python application to a VPS with Docker by writing a small, version‑pinned Dockerfile, defining a Compose file that describes your app and its database, and enabling a restart policy so it comes back after a reboot. Environment values go in configuration, not in image layers, and sensitive values are passed as files using Docker secrets. Compose creates a network for you so the app reaches the database by service name.
This picks up from a server you can already reach over SSH.
Before you start
- You need a Hostworld Linux VPS running AlmaLinux 9 or Ubuntu 24.04. If you are choosing one, see our Linux VPS.
- Use pinned image tags, not
:latest.latestonly means “most recently pushed”. Pinning versions avoids surprise upgrades that can break production. - Environment variables are not secret. Docker stores them in plaintext in container configuration and they are visible via the API and
docker inspect. Use secrets files for passwords and tokens. - Make your database persistent. Containers are ephemeral. Map a volume to the database’s data directory so you do not lose data on redeploy.
- Let Docker start on boot. If Docker does not start with the VPS, containers with restart policies cannot restart either.
- Avoid data‑destroying cleanups.
docker compose down -vremoves volumes and any databases in them.docker system prune -a --volumesdeletes unused images and volumes. Only run these if you mean to wipe data. - If you get stuck on a Hostworld VPS, open a support ticket. Tickets are the quickest way to reach our engineers and keep the history with your account.
Step 1: Install Docker Engine and Docker Compose
Install Docker from Docker’s repository so you get current packages for your OS.
Ubuntu 24.04
Create the keyring directory and add Docker’s signing key. This lets apt verify packages from Docker’s repository.
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
Add Docker’s apt source for Ubuntu 24.04 (Noble) so apt knows where to fetch Docker packages.
echo "Types: deb
URIs: https://download.docker.com/linux/ubuntu
Suites: noble stable
Components: stable
Architectures: $(dpkg --print-architecture)
Signed-By: /etc/apt/keyrings/docker.asc" | sudo tee /etc/apt/sources.list.d/docker.sources > /dev/null
Update apt’s package index, then install the Docker Engine, CLI, container runtime, and the Buildx and Compose plugins.
sudo apt-get update
sudo apt-get install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
AlmaLinux 9
Install the DNF plugins, then add Docker’s YUM repo for RHEL‑compatible distributions so DNF can fetch Docker packages.
sudo dnf -y install dnf-plugins-core
sudo dnf config-manager --add-repo https://download.docker.com/linux/rhel/docker-ce.repo
Install the Docker Engine, CLI, container runtime, and the Buildx and Compose plugins.
sudo dnf install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
Enable Docker at boot
Enable and start Docker now so containers and restart policies work across reboots.
sudo systemctl enable --now docker
Optional: Run Docker without sudo
Add your user to the docker group so you do not need sudo for Docker commands, then log out and back in for it to take effect.
sudo usermod -aG docker $USER
Step 2: Create your project folder and a .dockerignore
Create a working directory on your VPS for your app’s Docker files. A .dockerignore file keeps the build context small so builds are faster and images are smaller.
mkdir -p ~/apps/myapp
cd ~/apps/myapp
Create .dockerignore and exclude files you do not want sent to the Docker daemon during builds.
cat > .dockerignore <<'EOF'
.git
node_modules
venv
__pycache__
*.pyc
.env
secrets/
.DS_Store
EOF
Step 3: Write a small, version‑pinned Dockerfile for Node (option A)
Use a multi‑stage Dockerfile so the runtime image contains only what the app needs. Pin the base image to a major version to avoid accidental upgrades. The official Node image already provides a non‑root user named node.
# Dockerfile.node
# Stage 1: install production dependencies
FROM node:20-alpine AS deps
WORKDIR /app
COPY package*.json ./
# Install only production dependencies to keep the image lean
RUN npm ci --omit=dev
# Stage 2: copy app code and run as non-root
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules
COPY . .
USER node
EXPOSE 3000
# Replace server.js with your app's entry point
CMD ["node", "server.js"]
# Build-time secret example (optional)
# If you need a private registry token at build time, enable BuildKit and use:
# In the deps stage, before npm ci:
# RUN --mount=type=secret,id=NPM_TOKEN \
# sh -lc 'echo "//registry.npmjs.org/:_authToken=$(cat /run/secrets/NPM_TOKEN)" > ~/.npmrc' \
# && npm ci --omit=dev
This pattern keeps the final image small, runs as a non‑root user, and avoids copying your VCS history or development artefacts.
Step 4: Write a small, version‑pinned Dockerfile for Python (option B)
Use a minimal Python image, install dependencies without leaving caches, and run as a dedicated non‑root user. A two‑stage build avoids leaving build tools in the final image if you need them.
# Dockerfile.python
# Stage 1: build a virtualenv with dependencies
FROM python:3.12-slim AS builder
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
COPY requirements.txt .
RUN python -m venv /venv \
&& /venv/bin/pip install --no-cache-dir -r requirements.txt
COPY . .
# Stage 2: runtime with only the venv and your code
FROM python:3.12-slim AS runner
WORKDIR /app
ENV PATH="/venv/bin:$PATH" PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
# Create an unprivileged user to run the app
RUN useradd -u 10001 -m appuser
COPY --from=builder /venv /venv
COPY . .
USER appuser
EXPOSE 8000
# Replace app with your module or WSGI/ASGI entrypoint as needed
CMD ["python", "-m", "app"]
# Build-time secret example (optional)
# For private indexes at build time, mount a secret and point pip to it:
# RUN --mount=type=secret,id=PIP_CONFIG \
# sh -lc 'cp /run/secrets/PIP_CONFIG /etc/pip.conf' \
# && /venv/bin/pip install --no-cache-dir -r requirements.txt
Using --no-cache-dir with pip reduces layer size. If your dependencies require compilers, keep the toolchain in the builder stage only.
Step 5: Set environment and secrets without baking them into images
Create a .env file for Compose variable interpolation. This is not secret. It lets you keep one Compose file and vary values per environment.
cat > .env <<'EOF'
APP_PORT=3000
PY_APP_PORT=8000
POSTGRES_DB=appdb
POSTGRES_USER=app
EOF
Create a directory for secrets and put sensitive values in files. These files are mounted into containers at runtime through Docker secrets and are not stored in image layers.
mkdir -p secrets
# Replace with a strong password
echo "change_this_db_password" > secrets/db_password.txt
chmod 600 secrets/db_password.txt
Important notes:
- Compose auto‑loads
.envfor interpolation in the Compose YAML. Those values are not available inside containers unless you add them under a service’senvironment:orenv_file:. - Use top‑level
secrets:to mount secret files in containers. Many official images, including Postgres, support the*_FILEpattern to read credentials from a file path. - Do not put secrets in
ENVorARGin a Dockerfile. Build‑time secrets belong in BuildKit secret mounts. Runtime secrets belong insecrets:and are mounted as files.
Step 6: Add a database with persistence and a healthcheck
Define a Postgres service with a named volume for data and a healthcheck so your app waits until the database is ready. For Postgres 18 and newer, data lives under a versioned subdirectory beneath /var/lib/postgresql. Mounting the parent path simplifies upgrades.
# db.fragment.yaml
services:
db:
image: postgres:16
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
# Use the _FILE variant so the password comes from a secret file
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
secrets:
- db_password
volumes:
- pg_data:/var/lib/postgresql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d ${POSTGRES_DB}"]
interval: 5s
timeout: 3s
retries: 5
start_period: 10s
volumes:
pg_data:
secrets:
db_password:
file: ./secrets/db_password.txt
This gives you a persistent database that starts on boot and reports healthy when it is ready to accept connections.
Step 7: Write Compose for Node or Python and connect to the database
Compose creates a project network automatically. Services on that network resolve each other by service name. Your app reaches Postgres at hostname db on port 5432. Choose the example that matches your stack, then save it as compose.yaml in your project directory.
Node Compose example
# compose.yaml (Node)
services:
app:
build:
context: .
dockerfile: Dockerfile.node
restart: unless-stopped
environment:
# Your app should read the password from this file
DB_HOST: db
DB_PORT: "5432"
DB_NAME: ${POSTGRES_DB}
DB_USER: ${POSTGRES_USER}
DB_PASSWORD_FILE: /run/secrets/db_password
NODE_ENV: production
secrets:
- db_password
depends_on:
db:
condition: service_healthy
ports:
- "${APP_PORT}:3000"
db:
image: postgres:16
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
secrets:
- db_password
volumes:
- pg_data:/var/lib/postgresql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d ${POSTGRES_DB}"]
interval: 5s
timeout: 3s
retries: 5
start_period: 10s
volumes:
pg_data:
secrets:
db_password:
file: ./secrets/db_password.txt
Python Compose example
# compose.yaml (Python)
services:
app:
build:
context: .
dockerfile: Dockerfile.python
restart: unless-stopped
environment:
DB_HOST: db
DB_PORT: "5432"
DB_NAME: ${POSTGRES_DB}
DB_USER: ${POSTGRES_USER}
DB_PASSWORD_FILE: /run/secrets/db_password
secrets:
- db_password
depends_on:
db:
condition: service_healthy
ports:
- "${PY_APP_PORT}:8000"
db:
image: postgres:16
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
secrets:
- db_password
volumes:
- pg_data:/var/lib/postgresql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d ${POSTGRES_DB}"]
interval: 5s
timeout: 3s
retries: 5
start_period: 10s
volumes:
pg_data:
secrets:
db_password:
file: ./secrets/db_password.txt
In both examples:
restart: unless-stoppedmakes containers auto‑start after a VPS reboot unless you manually stopped them.depends_onwithcondition: service_healthytells Compose to wait until Postgres passes its healthcheck before starting your app. This avoids connection races during boot.- The default network lets the app reach the database using the service name
dbas the hostname.
Step 8: Build the image and start the stack
Build and start the services in the background so the app and database run as daemons.
docker compose up -d --build
Check container status so you know both services are running and healthy.
docker compose ps
View logs so you can spot application errors during first boot.
docker compose logs -f app
If you used a build‑time secret in your Dockerfile, pass it at build time. This example provides an NPM token to the Node Dockerfile’s secret mount.
# Build with a secret from the current shell environment
NPM_TOKEN="your_token_here" docker buildx build \
--secret id=NPM_TOKEN,env=NPM_TOKEN \
-f Dockerfile.node -t my-node-app:1.0 .
Compose will create a <project>_default network and attach both services. The app resolves db via built‑in DNS. You do not need to create a custom network for this pattern.
Step 9: Make sure it starts after a VPS reboot
Confirm Docker is enabled at boot so restart policies can take effect.
sudo systemctl enable docker
Restart the VPS to test. You can reboot over SSH or from the Virtualizor control panel in the Hostworld client area. After the server comes back, check that the containers are up.
docker compose ps
If Docker does not start, or containers do not reappear with the unless-stopped policy, open a support ticket and we will help you diagnose it on your Hostworld VPS.
Step 10: Deploy updates safely
Rebuild and apply changes without downtime so the latest image replaces the current container.
docker compose up -d --build
Be careful with destructive commands. docker compose down -v deletes volumes, including your database. docker system prune -a --volumes removes unused images and volumes. Only use these if you intend to wipe data and have backups in hand.
What next
If you want this on a new server in the UK, look at our Linux VPS range. For more walkthroughs and operational tips, see our VPS guides.
The next step in the playbook is adding HTTPS and a reverse proxy in front of your container. That is where you bind your domain to the app and manage TLS certificates cleanly.
Common questions
How do services find each other without IPs?
Compose creates a user‑defined network per project. Containers on that network resolve each other by service name through Docker’s embedded DNS. Your app connects to Postgres using hostname db on port 5432. No manual IP management is required.
My app starts before Postgres is ready. How do I fix that?
Add a healthcheck to Postgres using pg_isready, then set depends_on: with condition: service_healthy on your app. Compose will wait for the database to report healthy before starting the app, which avoids race conditions on boot.
Why did my database vanish after a redeploy?
If you changed the volume mapping for Postgres or ran docker compose down -v, Docker created a new empty data directory or deleted the volume. Map a named volume to the database’s data directory and do not remove it unless you intend to reset the database.
Are environment variables safe for secrets?
No. Environment variables are stored in plaintext in container config and visible via the Docker API and docker inspect. Use Docker secrets to mount passwords and tokens as files at runtime. For build‑time credentials, use BuildKit secret mounts so they do not end up in image layers.
Why pin image tags instead of using latest?
latest just means “most recently pushed”. It is not a guarantee of stability. Pinning versions, or even digests, prevents surprise upgrades when you rebuild, which reduces outages.