Docker on a VPS — self-hosting containers without Kubernetes
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
---
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).
```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:
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.
- your reverse proxy (80/443),
- SSH (22),
- maybe a private admin tool behind authentication/VPN.
---
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)
```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
```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
```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.
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)
- Prefer database-native backups (e.g., `pg_dump`) on a schedule
- Additionally snapshot volume directories if your VPS provider supports it
```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
- One reverse proxy stack (Traefik/Caddy)
- Several independent Compose stacks:
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
- you’re still learning basic networking, storage, and TLS
- you’re on a single VPS
- you mainly want “run a few services reliably”
---
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.