Technology

Docker on a VPS: Setup, Compose and Persistent Data

Docker on a VPS helps keep application dependencies in separate images and describes services in a repeatable Compose project. It can reduce dependency conflicts, but published host ports, data and upgrades still need planning. Reverting an image does not undo database migrations or other persistent changes.

This guide starts with a fresh Ubuntu 24.04 LTS installation and builds a small Node.js, Nginx and PostgreSQL project with all required files. The examples publish HTTP on loopback for testing. Before internet deployment, complete domain/TLS configuration, access controls and a recovery plan. Containers share the host kernel; this is a setup for services under one trusted administrator, not a sufficient boundary between mutually untrusted tenants.

Why Run Docker on a VPS Instead of Installing Everything Directly?

Before jumping into commands, it is worth understanding what you actually gain by putting Docker on a VPS instead of installing applications directly on the host.

Containers vs virtual machines in simple terms

A VPS based on hardware virtualization is a virtual machine: it has its own virtual CPU, RAM, storage and network, isolated from other customers on the same physical server. Docker works one layer above this. Instead of virtualizing hardware, containers share the host kernel and use namespaces to isolate their workspaces, including processes, file systems and networking. This is not a separate-kernel security boundary.

In practice, this means:

  • Lighter than VMs: containers avoid a separate guest operating system, generally reducing startup and resource overhead. Application startup time still varies.
  • Reproducible: a Dockerfile defines how to build an image; retain runtime configuration and dependency versions as well to reproduce the environment.
  • Isolated: separation depends on namespace, network and mount configuration. Containers still share the VPS kernel and resources.
  • Disposable: you can destroy and recreate containers without losing data stored in volumes.

We covered how this trend is reshaping hosting in our article about containerization trends in VPS technology. Here we focus on the hands‑on part.

Real‑world problems Docker solves on a VPS

  • Conflicting dependencies: applications can use separate supported runtime versions.
  • Ports: two databases may use the same container port on separate networks; they cannot both publish the same host address and port.
  • Upgrades: test a new image alongside a release plan; rollback still depends on schema and data compatibility.
  • Resource use: CPU, memory and process limits reduce a service’s impact, but must be configured. Disk pressure, kernel faults and a host outage can affect the whole stack.

For projects that fit one host, Compose can provide manageable service definitions without a cluster orchestrator. It does not provide independent failure domains or automatic high availability; operating the VPS, patching it and recovering its data remain your responsibility.

Preparing Your VPS for Docker

The commands use a clean Ubuntu 24.04 LTS system with a sudo-capable administrator and working SSH access. Use amd64 or arm64 for compatibility with all selected image tags; not every architecture supported by Docker is available in these tags. Docker also documents Debian and other distributions, but repository and package instructions differ. Do not add the Ubuntu repository to Debian. On a host already running Docker, Podman, containerd or a hosting panel, inspect existing services and Docker’s conflicting-package list before changing packages. For a comparison, see choosing a Linux distro for your VPS.

Starting points for VPS sizing

Size the complete workload rather than counting containers. Measure or estimate these components separately:

  • Application processes, database cache and connection pools, background workers and traffic peaks.
  • Image builds, package downloads and temporary memory use during deployment.
  • Host services, filesystem cache and an operating reserve; configured container limits must leave room for these.
  • Application data, uploads, image layers, logs, database growth and backup retention.

There is no universal 20–40 GB disk requirement or fixed number of containers per VPS. Set CPU, memory and process budgets, measure under representative load, and monitor disk/inodes as well as free memory. An out-of-memory kill, a full disk or a busy shared database can still disrupt several services.

Update and secure the base system

Before installing Docker, make sure your VPS is updated and not exposed with default settings. We go much deeper in our security guides like how to secure a VPS server without leaving doors open and the more checklist‑oriented VPS security hardening checklist, but here is a minimum:

  • Connect through SSH using a verified administrator account.
  • Update the operating system and reboot when required; confirm SSH access afterward.
  • Test key-based access in a second session before tightening password or administrator access rules.
  • Configure the host firewall while preserving management access. Docker-published ports require separate consideration, explained below.

If you want to go further with SSH protection, the article VPS SSH hardening without the drama shows modern options like FIDO2 keys and SSH certificate authorities.

Installing Docker on Your VPS (Step by Step)

The repository below is explicitly Ubuntu Noble (24.04). It installs Docker Engine, Buildx and the maintained Compose plugin from Docker’s APT repository. The loopback examples require Docker Engine 28 or later. It is a fresh installation procedure, not an in-place upgrade or a replacement for an existing container runtime.

1. Install prerequisites

Install these prerequisite packages on the fresh Ubuntu host:

sudo apt update
sudo apt install -y ca-certificates curl openssl

2. Add Docker’s official GPG key and 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
sudo tee /etc/apt/sources.list.d/docker.sources > /dev/null <<EOF
Types: deb
URIs: https://download.docker.com/linux/ubuntu
Suites: noble
Components: stable
Architectures: $(dpkg --print-architecture)
Signed-By: /etc/apt/keyrings/docker.asc
EOF
sudo apt update

3. Install Docker Engine and related tools

sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
sudo systemctl enable --now docker
sudo docker version
sudo docker compose version
sudo docker run --rm hello-world

This installs:

  • docker-ce: Docker Engine daemon.
  • docker-ce-cli: Docker command-line client.
  • containerd.io: container runtime package.
  • docker buildx / compose: extended build features and the modern docker compose subcommand.

4. Enable and test Docker

The installation commands above enable Docker at boot, print client/server and Compose versions, and run hello-world. If any command fails, resolve that error before proceeding.

If everything is correct, Docker will pull the hello-world image and print a confirmation message.

5. Allow a user to run Docker without sudo (optional; grants root-level access)

With this rootful installation, Docker commands normally require sudo. Adding a user to the docker group removes that requirement but grants root-level privileges on the VPS. Only give this access to trusted administrators:

sudo usermod -aG docker "$USER"

Log out and reconnect after this optional change. The rest of this guide keeps sudo so it also works when you do not grant docker-group membership. Do not add application or tenant accounts to the group.

For stricter security, consider rootless Docker or user namespace remapping. We will touch on those in the hardening section and you can dive deeper in our dedicated article safer containers with rootless Docker and Podman.

Running Your First Isolated Container

Let us move beyond hello-world and run a simple web server in a container. We will use Nginx as an example, but the same basic model applies to other application images, with image-specific ports, configuration and storage requirements.

Basic Nginx container on a VPS

Use a dedicated container name and a free loopback port. This example stays reachable only through the VPS’s IPv4 loopback address:

sudo docker run -d --name demo-nginx --restart unless-stopped \
  --publish 127.0.0.1:8081:80 nginx:1.30.6
curl --fail http://127.0.0.1:8081/

What this does:

  • -d runs in the background; --name demo-nginx identifies this exercise.
  • --publish 127.0.0.1:8081:80 maps host loopback port 8081 to container port 80.
  • --restart unless-stopped restarts after exit unless manually stopped; host reboot also requires Docker to start.
  • nginx:1.30.6 selects a versioned image. Tags remain mutable: consider digest pinning with a planned security update process.

Run curl on the VPS. Opening the VPS’s public IP will not reach this loopback-only demo. Check name and port ownership before retrying a conflicting command. The later examples use different names and ports so they can coexist.

Persisting content with volumes

A stopped container retains its writable layer, but removing it deletes that layer. A bind mount or named volume keeps selected data outside that lifecycle. Neither protects against loss of the host disk. The next example replaces only the demo-nginx exercise container and serves a file held on the host:

mkdir -p "$HOME/docker-demo/html"
cat > "$HOME/docker-demo/html/index.html" <<'EOF'
<!doctype html><html lang="en"><title>Docker demo</title><h1>Hello from Docker</h1></html>
EOF
sudo docker stop demo-nginx
sudo docker rm demo-nginx
sudo docker run -d --name demo-nginx --restart unless-stopped \
  --publish 127.0.0.1:8081:80 \
  --mount "type=bind,src=$HOME/docker-demo/html,dst=/usr/share/nginx/html,readonly" \
  nginx:1.30.6
curl --fail http://127.0.0.1:8081/

Now Nginx serves your custom index.html. The important part:

  • The host directory is mounted read-only inside Nginx; its index.html survives replacement of this container.
  • A bind mount exposes the selected host path with its filesystem permissions. Do not bind broad host directories or the Docker socket into an application.
  • For databases, use an image-specific data path and a database-aware backup. Copying a live database directory does not by itself make a consistent backup.

Understanding isolation: what is actually separated?

When we say “isolated Docker containers”, we are mainly talking about:

  • Processes: the default PID namespace separates process views. Host PID sharing changes that boundary.
  • Filesystems: containers have separate writable layers, with explicitly mounted data shared where configured.
  • Networks: user-defined networks connect selected services; published ports and shared networks change exposure.
  • Resources: cgroups support CPU, memory and process limits, but these are not automatically an adequate workload budget.
  • Kernel: the host kernel is shared. Non-root processes and rootless Docker reduce some risks but do not create independent VM kernels.

This separation helps you run multiple versions of the same service (e.g., Nginx, MySQL) on one VPS. Keep their data separate and avoid conflicting host-port mappings; separate Docker networks do not resolve a conflict on the same VPS address and port. Containers still share the host kernel, so this is not a guarantee against host compromise.

Structuring Multiple Isolated Containers on One VPS

Once you are comfortable with a single container, the next step is running multiple applications side by side in a way that stays maintainable over time. Here are key building blocks.

Create a separate Docker network per project

A user-defined Docker network lets attached containers communicate by name and helps separate project traffic. It is not a complete security boundary, especially when ports are published. For each project or client, create a dedicated network:

sudo docker network create network-demo

Then attach containers to this network:

sudo docker run -d --name network-demo-web --network network-demo \
  --publish 127.0.0.1:8082:80 nginx:1.30.6
curl --fail http://127.0.0.1:8082/

Containers on a user-defined bridge can resolve one another by name. A database attached to it need not publish its port to the host. Database authentication still applies; membership of the same network is not application authorization. A network merely named internal is different from a network configured with internal: true.

One container per responsibility

A common beginner mistake is trying to put everything in a single container: web server, application runtime, database, cron jobs, etc. This makes updates and scaling harder. A better pattern is:

  • web: Nginx or Caddy for static files and reverse proxy.
  • app: PHP‑FPM, Node.js, Python, etc., serving the application.
  • db: MySQL, MariaDB, PostgreSQL, Redis, etc.
  • worker: background job runners, queues, schedulers.

Connect only services that need to communicate, and keep database and cache ports unpublished. An internet-facing reverse proxy needs a real HTTP/TLS configuration and certificate lifecycle; mapping host port 443 alone does not create HTTPS.

Using docker compose for bigger stacks

Compose declares the stack in a project file, such as compose.yaml. The final section supplies a complete project. Run the following from its root only after creating all of its files:

sudo docker compose config --quiet
sudo docker compose up -d --build --wait --wait-timeout 180

Compose is ideal for common setups like WordPress, Laravel or custom microservices. If you are interested in a more detailed example, see our practical guide WordPress on Docker Compose with Nginx, MariaDB, Redis and automatic backups. Even if you are not running WordPress, the patterns apply to most PHP and Node.js apps.

Restart policies to keep containers alive

On a VPS you want your containers to start automatically after a reboot or a crash. Add a restart policy to your docker run or compose definitions:

  • --restart unless-stopped – restart after exit or a Docker daemon restart, unless you manually stop the container. Starting after a VPS reboot also requires Docker itself to start at boot.
  • --restart on-failure – restart only when the container exits with an error.

For a separate restart-policy exercise, use the new name restart-demo and free loopback port 8083. It serves the image’s default page, independently of the earlier bind-mounted content:

sudo docker run -d --name restart-demo --restart unless-stopped \
  --publish 127.0.0.1:8083:80 nginx:1.30.6

Security and Isolation Best Practices for Docker on a VPS

Container configuration, Docker daemon access and host maintenance form one security model. Define which users and services are trusted, expose only required endpoints, and test the resulting access from outside the VPS before publishing it.

1. Limit the Docker API and daemon exposure

  • Do not expose Docker’s TCP API to the public internet for this setup.
  • By default Docker listens on a Unix socket (/var/run/docker.sock). Keep it that way and only allow trusted users to access it.

2. Prefer non‑root containers

The Node application below uses the image’s node user. That is different from running the Docker daemon in rootless mode. Stock Nginx and PostgreSQL images have image-specific startup and permission requirements; arbitrarily overriding their user can break initialization or access to mounted data.

Practical steps:

  • Use the image’s documented runtime user and grant only necessary filesystem access.
  • Run application code as a non-root user when supported; confirm its UID can read its explicitly mounted secret files.
  • Treat rootless Docker and user namespace remapping as separate deployment choices with networking and storage implications.
  • Do not expose a Docker management socket or a privileged host interface to the application.

3. Run containers with the minimum privileges they need

Avoid --privileged unless you have a very specific reason. Some safer flags and patterns:

  • A read-only root filesystem with specific writable tmpfs/volume paths where needed.
  • Dropping capabilities the application does not need and enabling no-new-privileges; the Node example drops all capabilities.
  • Separate project networks and unpublished database ports.
  • Avoiding privileged mode and host PID/network sharing unless an explicitly reviewed requirement needs them.

4. Keep images small and up to date

Choose a maintained image compatible with your application. Small images can reduce unused packages, but size alone does not establish security; Alpine’s musl-based environment can change dependency compatibility. Version tags are clearer than latest, and a digest fixes the image content. Both require planned updates. Compose pull fetches service images; it does not rebuild your local application.

sudo docker compose pull db web
sudo docker compose build --pull app
sudo docker compose up -d --wait --wait-timeout 180
sudo docker compose ps
curl --fail http://127.0.0.1:8080/db-time

Run this only from the complete project below after testing chosen image/dependency changes. Publish application changes with a new image version rather than repeatedly overwriting a release tag. Preserve the previous image and check database/schema compatibility before rollback. Compose up on one VPS can interrupt service; it is not a guaranteed rolling deployment.

Combine this with a regular VPS patching routine as described in our guide how to secure a VPS server.

5. Use the VPS firewall as a second boundary

Use the VPS firewall as a second boundary, but do not assume ordinary host rules filter Docker-published ports. Docker can divert published-port traffic before UFW’s INPUT/OUTPUT rules, and its firewalld integration can permit forwarding. Inspect the actual forwarding rules for your Docker firewall backend and verify external reachability:

  • Publish only endpoints that are intended to be reachable. The demos bind loopback and do not publish database port 5432.
  • For public deployment, configure a valid certificate and renewal process at a reverse proxy, then expose required HTTP/HTTPS entry points.
  • Test IPv4 and IPv6 access from a separate external host. An unpublished port and an apparently permissive/restrictive UFW rule are different controls.
  • Keep monitoring dashboards and administration interfaces behind an appropriate access policy.

For more advanced packet‑level rules with IPv4 and IPv6, our cookbook on using nftables as a firewall on a VPS can help you build a layered defence.

Monitoring, Logs and Backups for Docker on a VPS

Once your containers are running, you must be able to see what is happening and protect your data. Containers are ephemeral; your data should not be.

Basic monitoring and log inspection

sudo docker compose ps
sudo docker compose logs --tail=100 app db web
sudo docker stats --no-stream
sudo docker system df

For standalone exercises, use sudo docker logs –tail=100 followed by their exact container name. logs captures stdout/stderr retained by the configured driver; it does not automatically read arbitrary application log files. Monitor restarts, application errors, CPU/memory, disk/inode space and log growth. The project uses bounded local logs; rotation is not backup or long-term audit storage.

Prometheus/Node Exporter and Grafana can monitor the host; Loki with Grafana Alloy can collect logs. Promtail reached end of life on March 2, 2026 and should not be selected for a new setup. Protect these systems’ endpoints and choose actionable alerts. See our guide to monitoring VPS resource usage with htop, iotop, Netdata and Prometheus.

Where to store persistent data

There are two main patterns:

  • Bind mounts: you choose the host path. Layout, permissions and portability are your responsibility. Use read-only mounts for configuration where possible.
  • Named volumes: Docker manages the storage. They survive ordinary container replacement but remain dependent on their backing host/storage.
  • Image-specific paths: PostgreSQL 17 uses /var/lib/postgresql/data; PostgreSQL 18+ uses a different layout. Do not change the major image tag without a database upgrade plan.

For files managed as bind mounts, keep each project’s paths explicit, for example:

/srv/project1/
  app/
  config/
  uploads/
/srv/project2/
  app/
  config/
  uploads/

Mount only the required path and match the image’s permissions. This guide uses read-only bind mounts for Nginx configuration and database initialization, and a named volume for PostgreSQL data. A named volume is not a backup; the same VPS failure can remove both service and data.

Backing up Docker data on a VPS

Backups should focus on data and configuration, not container internals. Typically you back up:

  • Versioned application source, dependency lock and build files.
  • Database-aware dumps or supported consistent physical backups; do not assume live data-directory copies are valid.
  • Uploaded files and user content, coordinated with application/database state where necessary.
  • Compose and proxy configuration without secrets; protect secrets and recovery keys separately from the code repository.

Common strategies:

  • Use a database-native tool such as pg_dump for a consistent logical backup; the complete project below includes an example.
  • Transfer completed backups to independent storage with encryption and a retention policy. A sync tool alone does not provide backup history or database consistency.
  • Alert on failed jobs and stale backups, then restore into an isolated environment and validate actual application data.
  • Define acceptable data loss and recovery time; losing the whole VPS is a different recovery scenario from replacing one container.

We show practical, encrypted and versioned setups with these tools in our guide to offsite backups with Restic/Borg to S3‑compatible storage. The same principles apply whether your workloads run in Docker or on bare metal.

Automating lifecycle tasks

As you grow more comfortable, you can automate:

  • Updates: build and test new application images, stage dependency changes and recreate services in a planned window.
  • Backups: schedule database-aware scripts and independent storage transfer; detect incomplete/failed jobs.
  • Health: monitor application readiness and database access. A Docker healthcheck changing to unhealthy does not itself restart the process.
  • Recovery: keep a tested rollback plan that includes data/schema compatibility; Compose does not automatically undo migrations.

Terraform and Ansible can reproduce documented VPS configuration, including the Docker installation. Keep data, secrets and recovery prerequisites under their own controls. See automating VPS setup with Terraform and Ansible.

Putting It All Together: A Simple Multi‑Container Example

The following project has all application, proxy, database and startup files. It deliberately uses loopback HTTP so it can be inspected before public deployment. It is a small demonstration of service separation and data handling, not a finished customer application or an internet-ready TLS setup.

Node.js, Nginx and PostgreSQL: A Complete Local Demo

Use the fresh Ubuntu/Docker installation above. The application uses Node 22.23.4-bookworm-slim and pg 8.23.1; Nginx is 1.30.6 and PostgreSQL is 17.11-bookworm. Node 22 maintenance support ends April 30, 2027. Keep the selected versions under a planned update policy rather than treating the tutorial pins as permanent.

  • The Node application serves / and /healthz; /db-time reads a fixed database timestamp query. It does not accept arbitrary SQL or contain application tables.
  • With database settings enabled, /readyz verifies an authenticated database query. Without those settings, standalone mode reports database disabled.
  • Only Nginx publishes host 127.0.0.1:8080. PostgreSQL uses a named volume and a separate non-superuser application role.
  • To expose the site later, configure a domain, TLS termination/renewal and external access tests. Merely mapping port 443 is insufficient.

High‑level design

  • web and app share frontend; app and db share backend. backend is explicitly internal: true, not merely named internal.
  • Database and application ports are unpublished. The application receives only its own password file, not the PostgreSQL superuser password.
  • Nginx uses Docker DNS resolution for app so recreation with a new container IP does not leave a permanently stale proxy destination.
  • All containers still share a host kernel and host availability. Bridge traffic is not TLS-encrypted here; separate hosts need appropriate database TLS and network access controls.

Create Every File, Then Start the Project

Create a new project directory and save each file under the indicated path. package-lock.json is generated after the application files; the two secret files are created before Compose starts.

mkdir -p "$HOME/docker-vps-demo/app" "$HOME/docker-vps-demo/db"
cd "$HOME/docker-vps-demo"
docker-vps-demo/
  app/
    package.json
    package-lock.json
    server.js
    Dockerfile
    .dockerignore
  db/init-app.sh
  secrets/postgres_password.txt
  secrets/app_db_password.txt
  .gitignore
  compose.yaml
  nginx.conf

app/package.json

{
  "name": "docker-vps-demo",
  "version": "1.0.0",
  "private": true,
  "engines": {
    "node": "22.x"
  },
  "scripts": {
    "start": "node server.js"
  },
  "dependencies": {
    "pg": "8.23.1"
  }
}

app/server.js

const http = require('node:http');
const fs = require('node:fs');
const { Pool } = require('pg');

function createServer(pool = null) {
  return http.createServer(async (req, res) => {
    const send = (code, body) => {
      res.writeHead(code, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
      res.end(JSON.stringify(body));
    };
    if (req.method !== 'GET') {
      res.setHeader('Allow', 'GET');
      return send(405, { error: 'method_not_allowed' });
    }
    let path;
    try { path = new URL(req.url, 'http://localhost').pathname; }
    catch { return send(400, { error: 'invalid_url' }); }
    if (path === '/healthz') return send(200, { status: 'ok' });
    if (path === '/') return send(200, { message: 'Docker VPS demo' });
    if (path === '/readyz' && !pool) return send(200, { status: 'ok', database: 'disabled' });
    if (path !== '/readyz' && path !== '/db-time') return send(404, { error: 'not_found' });
    if (!pool) return send(503, { error: 'database_not_configured' });
    try {
      const result = await pool.query('SELECT CURRENT_TIMESTAMP AS server_time');
      return send(200, path === '/readyz' ? { status: 'ok' } : { server_time: result.rows[0].server_time });
    } catch {
      return send(503, { error: 'database_unavailable' });
    }
  });
}

if (require.main === module) {
  const pool = process.env.DB_HOST ? new Pool({
    host: process.env.DB_HOST,
    port: 5432,
    database: 'demo',
    user: 'demo_app',
    password: fs.readFileSync(process.env.DB_PASSWORD_FILE, 'utf8').trim(),
    max: 5,
    connectionTimeoutMillis: 2000,
    idleTimeoutMillis: 10000,
    statement_timeout: 2000,
    query_timeout: 3000
  }) : null;
  if (pool) pool.on('error', () => console.error('Database connection lost'));
  const server = createServer(pool);
  server.requestTimeout = 10000;
  server.headersTimeout = 10000;
  server.listen(3000, '0.0.0.0', () => console.log('Listening on port 3000'));
  let stopping = false;
  const stop = () => {
    if (stopping) return;
    stopping = true;
    const deadline = setTimeout(() => process.exit(1), 8000);
    deadline.unref();
    server.close(async () => {
      if (pool) await pool.end();
      clearTimeout(deadline);
    });
  };
  process.on('SIGTERM', stop);
  process.on('SIGINT', stop);
}
module.exports = { createServer };

app/Dockerfile

FROM node:22.23.4-bookworm-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev --ignore-scripts && npm cache clean --force
COPY server.js ./
ENV NODE_ENV=production
USER node
EXPOSE 3000
CMD ["node", "server.js"]

app/.dockerignore

*
!package.json
!package-lock.json
!server.js
!Dockerfile

.gitignore

secrets/
backups/
.env
app/node_modules/

Only the manifest, lock and server.js enter the image. The restricted build context excludes host node_modules, Git history and secrets. USER node runs the application without root; EXPOSE documents port 3000 but does not publish it. The published npm packages in this example’s locked pg dependency tree include the required JavaScript files, so installation uses npm ci –ignore-scripts. Whenever the lockfile is regenerated, review whether the resulting dependencies require build or installation scripts.

Generate the lock from the project root. The one-off Node container writes to app using your host UID/GID; no host Node installation is needed. Commit the lock and use npm ci for subsequent image builds:

sudo docker run --rm --user "$(id -u):$(id -g)" \
  --env npm_config_cache=/tmp/npm-cache \
  --mount "type=bind,src=$PWD/app,dst=/work" --workdir /work \
  node:22.23.4-bookworm-slim npm install --package-lock-only --ignore-scripts

Optional standalone application exercise, using a separate name and loopback port:

sudo docker build -t docker-vps-demo:1.0 ./app
sudo docker run -d --name node-demo --restart unless-stopped \
  --publish 127.0.0.1:3000:3000 \
  --memory 256m --cpus 0.50 --pids-limit 100 \
  --read-only --tmpfs /tmp:rw,noexec,nosuid,size=16m \
  --cap-drop ALL --security-opt no-new-privileges:true \
  --log-driver local --log-opt max-size=10m --log-opt max-file=3 \
  docker-vps-demo:1.0
curl --fail http://127.0.0.1:3000/

No database is configured in this standalone container, so /db-time returns 503. The Compose application below uses the same image with database settings. Resource limits in this guide are illustrative budgets, not workload capacity promises.

Create two random secrets for this new project. The commands preserve existing files. The host directory is 0700 while the files are 0444: the parent restricts host users, while non-root processes can read explicitly granted bind-mounted secret files. Local Compose secrets are files, not encrypted storage.

umask 077
mkdir -p secrets
chmod 700 secrets
test ! -e secrets/postgres_password.txt && openssl rand -hex 32 > secrets/postgres_password.txt
test ! -e secrets/app_db_password.txt && openssl rand -hex 32 > secrets/app_db_password.txt
chmod 444 secrets/postgres_password.txt secrets/app_db_password.txt

db/init-app.sh

#!/bin/sh
set -eu
app_password=$(cat /run/secrets/app_db_password)
export app_password
psql --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" --set=ON_ERROR_STOP=1 <<'SQL'
\getenv app_password app_password
CREATE ROLE demo_app LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE PASSWORD :'app_password';
REVOKE ALL ON DATABASE demo FROM PUBLIC;
GRANT CONNECT ON DATABASE demo TO demo_app;
SQL
unset app_password

On the first empty data directory, the initialization script creates demo_app without superuser, database-creation or role-creation privileges. It grants connection to demo for the fixed SELECT query. A real application needs explicitly granted schema/table privileges. Initialization scripts do not rerun for an existing volume; editing a password file does not rotate an existing database role password.

nginx.conf

user nginx;
worker_processes 1;
error_log /dev/stderr warn;
pid /var/run/nginx.pid;
events { worker_connections 256; }
http {
  access_log /dev/stdout;
  server {
    listen 80;
    server_name _;
    resolver 127.0.0.11 valid=10s ipv6=off;
    resolver_timeout 5s;
    location / {
        set $app_backend app:3000;
        proxy_pass http://$app_backend;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $remote_addr;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_connect_timeout 5s;
        proxy_read_timeout 15s;
    }
  }
}

The Docker DNS resolver and variable proxy target allow app’s address to be resolved again after replacement. This complete Nginx configuration uses one worker and serves HTTP only. Its forwarded headers describe this direct proxy hop; a future upstream TLS proxy requires an explicit trusted-proxy configuration.

compose.yaml

name: docker-vps-demo
x-logging: &bounded-logs
  driver: local
  options:
    max-size: "10m"
    max-file: "3"
services:
  db:
    image: postgres:17.11-bookworm
    restart: unless-stopped
    environment:
      POSTGRES_DB: demo
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD_FILE: /run/secrets/postgres_password
    secrets:
      - postgres_password
      - app_db_password
    volumes:
      - db_data:/var/lib/postgresql/data
      - ./db/init-app.sh:/docker-entrypoint-initdb.d/init-app.sh:ro
    networks: [backend]
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -U postgres -d demo"]
      interval: 5s
      timeout: 3s
      retries: 12
      start_period: 20s
    mem_limit: 512m
    cpus: 0.75
    pids_limit: 200
    logging: *bounded-logs
  app:
    build: ./app
    image: docker-vps-demo:1.0
    restart: unless-stopped
    environment:
      DB_HOST: db
      DB_PASSWORD_FILE: /run/secrets/app_db_password
    secrets: [app_db_password]
    networks: [frontend, backend]
    depends_on:
      db:
        condition: service_healthy
    healthcheck:
      test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/readyz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
      interval: 10s
      timeout: 5s
      retries: 3
      start_period: 10s
    read_only: true
    tmpfs: ["/tmp:rw,noexec,nosuid,size=16m"]
    cap_drop: [ALL]
    security_opt: [no-new-privileges:true]
    mem_limit: 256m
    cpus: 0.50
    pids_limit: 100
    logging: *bounded-logs
  web:
    image: nginx:1.30.6
    restart: unless-stopped
    ports: ["127.0.0.1:8080:80"]
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf:ro
    networks: [frontend]
    depends_on:
      app:
        condition: service_healthy
    mem_limit: 128m
    cpus: 0.25
    pids_limit: 100
    logging: *bounded-logs
networks:
  frontend:
  backend:
    internal: true
volumes:
  db_data:
secrets:
  postgres_password:
    file: ./secrets/postgres_password.txt
  app_db_password:
    file: ./secrets/app_db_password.txt

Start from the project root after all files exist:

chmod 755 db/init-app.sh
sudo docker compose config --quiet
sudo docker compose up -d --build --wait --wait-timeout 180
sudo docker compose ps
curl --fail http://127.0.0.1:8080/
curl --fail http://127.0.0.1:8080/db-time

config checks the resolved Compose model; it does not run the containers. The database healthcheck checks TCP acceptance, while app’s readyz checks an authenticated query. depends_on waits during startup; it does not continuously enforce dependency health or restart an unhealthy process. Inspect failed logs rather than deleting the volume to hide initialization errors.

Check the role and actual named volume:

sudo docker compose exec -T db psql -U postgres -d demo -c '\du demo_app'
sudo docker compose exec -T db psql -U postgres -d demo -c 'SELECT current_database();'
sudo docker volume inspect docker-vps-demo_db_data

Ordinary compose down preserves named volumes; down –volumes may delete the database. This project’s name determines docker-vps-demo_db_data. PostgreSQL major upgrades need a supported upgrade procedure and compatible data layout.

Create a logical backup. Listing the archive is only a structural check, not proof of a successful restore:

umask 077
mkdir -p backups
backup_file="backups/demo-$(date -u +%Y%m%dT%H%M%SZ).dump"
if sudo docker compose exec -T db pg_dump -U postgres -d demo -Fc > "$backup_file.tmp"; then
  mv "$backup_file.tmp" "$backup_file"
  sudo docker compose exec -T db pg_restore --list < "$backup_file"
else
  rm -f "$backup_file.tmp"
  exit 1
fi

In the same shell session, restore into a separate, previously unused demo_restore_test database:

sudo docker compose exec -T db createdb -U postgres demo_restore_test &&
  sudo docker compose exec -T db pg_restore -U postgres -d demo_restore_test --exit-on-error < "$backup_file" &&
  sudo docker compose exec -T db psql -U postgres -d demo_restore_test -c 'SELECT current_database();'

The demo has no application tables. For a real application verify tables, representative records, permissions and application queries. pg_dump does not include role definitions; this demo recreates its role through the init script. Protect real role definitions, secrets and recovery prerequisites separately. The local restore exercise does not cover host loss; perform a full restore in an isolated environment too.

To view the HTTP demo remotely, run this on your own computer, substituting your real SSH account and host, then visit http://127.0.0.1:8080 locally:

ssh -L 8080:127.0.0.1:8080 your-user@your-vps

For a second project, use a different Compose project name, image version, data volumes and host port; copying only the directory is insufficient. Public projects can share one configured HTTPS entry proxy with domain-based routing. Two containers cannot both publish the same host address on 80/443. Separate networks reduce accidental communication but do not separate the kernel or host failure domain. Plan total resource and disk budgets across every project.

Conclusion: Your Next Step with Isolated Docker on a VPS

A useful Docker workflow makes the image version, application files, data location, service connections and recovery steps explicit. This guide gives you a standalone Node application and a three-service loopback HTTP stack with a working database query. It does not establish a fixed container capacity or automatic high availability.

Before public deployment, complete TLS, external access checks and recovery tests, then measure representative load and monitor errors, resources and backup age. Move workloads to separate VPSs when capacity, security boundaries or failure isolation require it. Reusing a Compose file does not transfer data, secrets, DNS or certificates automatically; those need a migration plan.

Primary Technical References

Frequently Asked Questions

A VPS can run Docker when its operating system, permissions and resources support the workload. Dedicated hardware is not a prerequisite. Measure applications, database, build/deployment peaks, storage and host reserve rather than assuming a fixed CPU/RAM plan will fit. Stronger tenant separation or independent failure domains may require separate VPSs or another architecture; containerization alone does not establish them.

There is no useful universal container count. Capacity depends on process memory, CPU, database connections, disk I/O, storage growth and traffic. Configure limits, monitor the complete host and test representative load. The example budgets do not promise a specific number of sites or users. Split workloads when measured capacity or isolation requirements call for it.

Namespaces separate selected process, filesystem and network views, while cgroups support resource limits. Containers still share a host kernel. Shared networks, mounts, published ports and daemon privileges change the boundary. Non-root application processes and rootless Docker address different risks; neither automatically creates separate VM kernels. Do not treat this single-administrator example as a sufficient boundary for mutually untrusted tenants.

Keep versioned source, dependency locks and non-secret build/Compose/proxy configuration. Create database-consistent dumps or supported physical backups, protect uploaded files, and store secrets and recovery keys separately from Git. Transfer completed backups to independent encrypted storage with retention. Restore into an isolated environment and verify real application data and permissions; archive listing alone is insufficient. pg_dump does not include global roles.
↑