Operations

A Production-Ready Docker Compose Setup on a VPS with Rootless Docker

Running application containers on a single VPS is one of the most common self-hosting patterns, and Docker Compose remains the pragmatic way to describe a multi-service stack in one file. The default Docker install runs the daemon as root, which means a container escape or a compromised daemon socket lands an attacker on your host with full privileges. Rootless mode removes that class of risk by running the daemon and your containers under an unprivileged user. This guide walks through a current (2026) install of Docker Engine and Compose v2 in rootless mode, then builds a realistic compose.yaml with restart policies, healthchecks, named volumes, resource limits, and log rotation, and closes with how to update the stack safely.

Install Docker Engine and Compose v2

On a fresh Debian or Ubuntu host, use the official convenience script to install Engine and the Compose v2 plugin. Compose v2 is a Docker CLI plugin invoked as docker compose (a subcommand, not the old docker-compose binary).

curl -fsSL https://get.docker.com | sh

# Verify the plugin is present
docker compose version

Compose v2 implements the open Compose Specification, so the file format is not tied to one vendor. That is worth knowing if you later migrate the same file to another runtime. The reference for every field below is the Compose file services reference.

Switch to rootless mode

Rootless mode runs the daemon as a non-root user to mitigate vulnerabilities in the daemon and runtime. Do the setup as the unprivileged user that will own your containers, not as root. Packages from 20.10 and later ship the setup tool at /usr/bin/dockerd-rootless-setuptool.sh.

# As the non-root user (for example: deploy)
dockerd-rootless-setuptool.sh install

# Start the user daemon and keep it running after logout
systemctl --user start docker
sudo loginctl enable-linger $(whoami)

# Point the CLI at the rootless socket (add to ~/.bashrc)
export PATH=/usr/bin:$PATH
export DOCKER_HOST=unix:///run/user/$(id -u)/docker.sock

The enable-linger step is important: without it, the user's systemd session (and your containers) stops when you log out. Full instructions are in the rootless mode documentation. Confirm you are actually rootless before deploying anything:

docker info -f '{{println .SecurityOptions}}'
# Expect an entry like: name=rootless

Rootless tradeoffs you must plan for

Rootless mode is not a drop-in for every workload. Two limits matter most on a public-facing VPS:

  • Privileged ports. An unprivileged daemon cannot bind ports below 1024 by default, so you cannot publish a container directly on port 80 or 443 out of the box. Either lower the threshold with sysctl net.ipv4.ip_unprivileged_port_start=80, grant the capability with sudo setcap cap_net_bind_service=ep $(which rootlesskit), or (cleaner) publish on a high port and put a host reverse proxy in front.
  • Networking performance. Rootless networking defaults to a userspace stack, which historically added overhead versus rootful. Modern releases reduce this, but for throughput-sensitive services benchmark before committing. The rootless documentation also lists storage driver and cgroup constraints worth reading.

Resource limits without swarm require cgroup v2 with systemd, which is standard on current distributions. If you deploy on such a host, the limits shown below are enforced.

A production-ready compose.yaml

The stack below runs a web service alongside a Postgres database. Every service declares a restart policy, a healthcheck, resource limits, and bounded JSON logs. Named volumes hold state so docker compose down never destroys your data.

name: app-stack

services:
  web:
    image: ghcr.io/example/web:1.8.2
    restart: unless-stopped
    ports:
      - "8080:8080"
    environment:
      DATABASE_URL: postgres://app:app@db:5432/app
    depends_on:
      db:
        condition: service_healthy
    healthcheck:
      test: ["CMD", "curl", "-fsS", "http://localhost:8080/healthz"]
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 20s
    deploy:
      resources:
        limits:
          cpus: "0.75"
          memory: 512M
    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "3"

  db:
    image: postgres:17-alpine
    restart: unless-stopped
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: app
      POSTGRES_DB: app
    volumes:
      - db-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d app"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 30s
    deploy:
      resources:
        limits:
          cpus: "1.0"
          memory: 1G
    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "3"

volumes:
  db-data:

Why each block is there

  • restart: unless-stopped restarts crashed containers and survives host reboots, but respects a manual stop. Use on-failure:5 for one-shot jobs and no for anything you debug interactively.
  • healthcheck reports readiness. Note the important caveat: an unhealthy container is not automatically restarted by Engine on a single host. What healthchecks buy you here is depends_on: condition: service_healthy, so web waits until Postgres actually accepts connections instead of merely having started.
  • deploy.resources.limits sets hard ceilings. Without them, one leaking container can starve the whole VPS. These limits are honored by docker compose up on modern Compose v2 with cgroup v2, no swarm required.
  • named volume db-data decouples state from the container lifecycle. Bind mounts are fine for config, but named volumes are the safer default for databases.
  • logging options cap the JSON log at three 10 MB files per container. The default json-file driver has no rotation, so an untended stack can fill the disk over months.

Bring the stack up and inspect it

# Start in the background
docker compose up -d

# Watch health transition from starting to healthy
docker compose ps

# Follow logs for one service
docker compose logs -f web

# Confirm limits are applied
docker stats --no-stream

Keep compose.yaml in a git repository alongside a .env file that stays out of version control. That makes the stack reproducible and the next deploy a diff you can review.

Updating without downtime surprises

Pin image tags to explicit versions, as in the example, rather than latest. An update is then a deliberate tag bump plus a pull and recreate:

# Pull the new pinned images referenced in compose.yaml
docker compose pull

# Recreate only changed services, respecting healthchecks
docker compose up -d

# Reclaim space from old image layers once healthy
docker image prune -f

Because Compose recreates containers with dependencies in order and waits on service_healthy conditions, a bad database image is caught before the web tier restarts against it. For a true zero-downtime rollout of a stateless service, run two replicas behind your reverse proxy and update them one at a time; single-container Compose services will have a brief gap during recreate.

Wrap-up

This setup runs on any VPS where you have full root to perform the one-time rootless setup, and thereafter your workloads run unprivileged. The pattern is small on purpose: pinned images, rootless daemon, restart policies, healthchecks gating dependencies, hard resource limits, named volumes for state, and rotated logs. Add a host reverse proxy for TLS and privileged ports, keep the compose file in version control, and updates stay boring, which is exactly what you want from production infrastructure. Read the rootless mode and Compose services references before hardening further.