Skip to content

Docker on a VPS — self-hosting containers without Kubernetes

8 min read

Why Docker on a VPS is a great starting point

A Virtual Private Server (VPS) gives you a public IP, root access, and predictable resources—perfect for self-hosting. Docker adds a lightweight way to run applications in isolated “containers” without the overhead of full virtual machines.

For beginners, the sweet spot is:

  • Docker Engine to run containers
  • Docker Compose to define multi-container apps (web app + database + cache, etc.)
  • A reverse proxy (commonly Traefik or Caddy) to route traffic to the right container and handle HTTPS
  • Volumes to keep data safe across container updates
This setup can run many real-world services reliably without jumping into Kubernetes.

---

Core Docker concepts (the minimum you need)

Images vs containers

  • Image: a read-only template (like an app “snapshot”) that includes dependencies and configuration defaults.
  • Container: a running instance of an image (the actual process and filesystem layer in memory/disk).
Example:

```bash docker pull nginx:alpine docker run -d --name web -p 8080:80 nginx:alpine ```

This downloads an Nginx image and runs it as a container named `web`, mapping VPS port `8080` to container port `80`.

Networking basics

Docker creates networks so containers can talk to each other by name.

  • Containers on the same user-defined network can reach each other via DNS:
- `http://db:5432` if the database container is named `db`.

Ports: publishing vs exposing

  • Publishing (`-p 8080:80`) makes a container reachable from outside the VPS.
  • Exposing is mostly documentation; it doesn’t publish to the host unless you map it.
A common best practice: only publish ports for:
  • your reverse proxy (80/443),
  • SSH (22),
  • maybe a private admin tool behind authentication/VPN.
Everything else stays internal.

---

Installing Docker and Compose (typical VPS workflow)

On most modern Linux VPS hosts (Ubuntu/Debian), you’ll install Docker Engine and the Compose plugin. Providers vary, so follow the official Docker instructions for your distro, but the goal is:

  • `docker` command works
  • `docker compose` command works (note the space—Compose v2)
Verify:

```bash docker --version docker compose version ```

Also consider adding your user to the `docker` group so you don’t need `sudo` for every command (understand the security implications: Docker access is effectively root).

---

Docker Compose: the workhorse for self-hosting

Docker Compose lets you describe a multi-container application in a `compose.yaml` file, then bring it up with one command.

Example: a simple web app + Postgres

```yaml services: app: image: ghcr.io/example/myapp:latest environment: DATABASE_URL: postgres://myuser:mypassword@db:5432/mydb depends_on: - db networks: - internal

db: image: postgres:16 environment: POSTGRES_USER: myuser POSTGRES_PASSWORD: mypassword POSTGRES_DB: mydb volumes: - db_data:/var/lib/postgresql/data networks: - internal

volumes: db_data:

networks: internal: ```

Bring it up:

```bash docker compose up -d docker compose ps docker compose logs -f app ```

Key points:

  • `depends_on` helps start order, but doesn’t guarantee “db is ready.” Many apps handle retries; if not, add healthchecks.
  • The database uses a named volume `db_data` so updates/recreates won’t wipe your data.
  • No ports are published—this stack is not publicly reachable yet. That’s intentional.
---

Reverse proxies: Traefik and Caddy for clean HTTPS hosting

If you host multiple services on one VPS, you usually want:

  • one public entry point (ports 80 and 443)
  • automatic HTTPS certificates (Let’s Encrypt)
  • routing by domain name (e.g., `app.example.com` → app container)

Option A: Traefik (dynamic, Docker-aware)

Traefik can watch Docker and configure routes based on container labels. This is popular for self-hosting because you don’t manually edit proxy config for every app.

A common pattern:

  • One Compose project for Traefik
  • Other Compose projects join Traefik’s external network
Traefik Compose (example):

```yaml services: traefik: image: traefik:v3.1 command: - --providers.docker=true - --providers.docker.exposedbydefault=false - --entrypoints.web.address=:80 - --entrypoints.websecure.address=:443 - --certificatesresolvers.le.acme.tlschallenge=true - --certificatesresolvers.le.acme.email=you@example.com - --certificatesresolvers.le.acme.storage=/letsencrypt/acme.json ports: - "80:80" - "443:443" volumes: - /var/run/docker.sock:/var/run/docker.sock:ro - letsencrypt:/letsencrypt networks: - proxy restart: unless-stopped

volumes: letsencrypt:

networks: proxy: name: proxy ```

Then your app stack can add labels:

```yaml services: app: image: ghcr.io/example/myapp:latest networks: - internal - proxy labels: - traefik.enable=true - traefik.http.routers.app.rule=Host(`app.example.com`) - traefik.http.routers.app.entrypoints=websecure - traefik.http.routers.app.tls.certresolver=le - traefik.http.services.app.loadbalancer.server.port=8080

networks: internal: proxy: external: true ```

Notes:

  • `exposedbydefault=false` prevents accidentally publishing everything.
  • The service port in labels (`8080`) must match what your container listens on internally.

Option B: Caddy (simple, readable, great defaults)

Caddy is loved for its simple configuration and automatic HTTPS. It’s not “Docker-aware” by default in the same way Traefik is, but it’s extremely approachable.

A typical approach:

  • Run Caddy as a container
  • Put your services on a shared Docker network
  • Write a `Caddyfile` that proxies to container names
Caddy Compose (example):

```yaml services: caddy: image: caddy:2 ports: - "80:80" - "443:443" volumes: - ./Caddyfile:/etc/caddy/Caddyfile:ro - caddy_data:/data - caddy_config:/config networks: - proxy restart: unless-stopped

volumes: caddy_data: caddy_config:

networks: proxy: name: proxy ```

Caddyfile example:

```caddyfile app.example.com { reverse_proxy app:8080 } ```

Then ensure your `app` container is also on the `proxy` network.

Traefik vs Caddy quick guidance:

  • Choose Traefik if you want “attach labels and it just appears.”
  • Choose Caddy if you want a clean, explicit config file and simpler mental model.
---

Volume management: keeping data safe

Containers are disposable; your data shouldn’t be.

Named volumes vs bind mounts

  • Named volume: managed by Docker (stored under Docker’s data directory). Good default for databases and app state.
  • Bind mount: maps a host path to a container path. Great for configs you want to edit directly, or when you need easy backups with regular filesystem tools.
Examples:

Named volume:

```yaml volumes: postgres_data:

services: db: image: postgres:16 volumes: - postgres_data:/var/lib/postgresql/data ```

Bind mount for configs:

```yaml services: caddy: volumes: - ./Caddyfile:/etc/caddy/Caddyfile:ro ```

Backup strategy (simple and effective)

At minimum:
  • Back up your Compose files (`compose.yaml`, `.env`, configs)
  • Back up volume data (especially databases)
A practical approach:
  • Prefer database-native backups (e.g., `pg_dump`) on a schedule
  • Additionally snapshot volume directories if your VPS provider supports it
For Postgres:

```bash docker exec -t your_db_container pg_dump -U myuser mydb > mydb.sql ```

Store backups off the VPS (object storage, another server, etc.).

---

Operating a VPS with Docker: practical habits

Use `.env` for secrets and settings

Compose can load environment variables from a `.env` file in the same directory. Avoid hardcoding passwords in the YAML.

`.env`:

```env POSTGRES_PASSWORD=change-me ```

Compose:

```yaml environment: POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} ```

For higher security, consider Docker secrets (more common in Swarm) or a secret manager, but `.env` is a common starting point.

Restart policies

For long-running services:

```yaml restart: unless-stopped ```

This helps services come back after a reboot.

Updating containers safely

Typical workflow:

```bash docker compose pull docker compose up -d docker image prune ```

Be mindful: major version bumps (e.g., Postgres 15 → 16) may require migration steps.

---

When Docker Compose is enough (most VPS setups)

Compose is often sufficient when:

  • You run single-node workloads (one VPS)
  • Downtime during updates is acceptable (seconds to a minute)
  • You want straightforward deployments and troubleshooting
  • Your scale is “a handful of services” (personal projects, small business tools, low/medium traffic sites)
  • You don’t need automatic rescheduling across multiple machines
A very common self-hosting layout is:
  • One reverse proxy stack (Traefik/Caddy)
  • Several independent Compose stacks:
- blog/CMS - monitoring - password manager - media service - custom apps

This is maintainable, debuggable, and cost-effective.

---

When to consider k3s (lightweight Kubernetes) instead

Kubernetes is powerful but adds operational complexity. k3s is a streamlined Kubernetes distribution, often chosen when people outgrow Compose.

Consider k3s if you need:

  • Multi-node clustering (high availability across multiple VPSes)
  • Self-healing scheduling (containers automatically move if a node dies)
  • Advanced rollout strategies (canary deployments, sophisticated health checks)
  • Horizontal scaling across nodes (replicas, autoscaling)
  • A platform for many teams/services with standardized tooling
Avoid switching too early if:
  • you’re still learning basic networking, storage, and TLS
  • you’re on a single VPS
  • you mainly want “run a few services reliably”
For many self-hosters, Compose remains the best long-term choice, and adding good backups + monitoring provides more value than adopting Kubernetes.

---

A sensible beginner architecture (recommended path)

1. Start with Docker + Compose on one VPS. 2. Add Traefik or Caddy as your only published entry point on 80/443. 3. Keep most services internal; expose them via the proxy and domain rules. 4. Use volumes for data, and plan backups from day one. 5. Only consider k3s when you truly need multi-node resiliency or scaling.

This approach will teach you the fundamentals (Linux, networking, TLS, storage) while delivering a production-like setup you can actually run and maintain.