Deploy Docker on Vultr VPS: Complete 2026 Setup Guide

Running Docker on a Vultr VPS is straightforward until it isn’t. Most guides get you through the install and call it a day, leaving out two things that will bite you in production: the Docker + UFW firewall bypass that silently exposes your containers to the internet, and the correct install method that actually keeps Docker up to date. We also ran the install on a fresh $5 Vultr instance on September 25, 2026 and wrote down what actually happened (install time, plus the RAM and disk left over with a real app container running) in its own section below. This guide covers the full path: choosing the right plan, installing Docker Engine properly, configuring Docker Compose V2, adding swap for low-RAM instances, and locking down your firewall the right way.

This post contains affiliate links. If you purchase through our links, we may earn a commission at no extra cost to you.

Deploy Docker on Vultr VPS: Complete 2026 Setup Guide — featured image showing Docker whale logo with terminal and Vultr VPS branding
Deploy Docker on Vultr VPS: Complete 2026 Setup Guide, featured image showing Docker whale logo with terminal and Vultr VPS branding
🛠️ Free tools for this guide: Docker RAM Calculator (size your containers before picking a plan) · Dockerfile Generator (generate a production-ready Dockerfile for your stack)

Why Vultr for Docker workloads?

Not all VPS providers are equal when you’re running containerized apps. Storage I/O matters more than most people realize for Docker: every container image pull, build layer, and database volume write touches disk. In Better Stack’s benchmark, Vultr’s High Performance AMD 2 vCPU / 4 GB plan measured roughly 118,000 combined 4K IOPS, compared to ~54,000 IOPS on DigitalOcean’s equivalent plan. That’s a 2.2x difference in disk throughput at the same price point, which shows up in faster container startup, snappier database queries inside containers, and noticeably quicker image builds.

The High Performance AMD plans run on AMD EPYC-Genoa at 3.25 GHz (Geekbench 6 single-core score: 1,926), which is strong for Docker workloads that benefit from high single-thread performance. Provisioning takes under 60 seconds, so you’re not waiting around.

For more benchmarks and the full product lineup, see the Vultr Review 2026.

Which Vultr plan should you pick for Docker?

Here’s the current plan lineup relevant to Docker deployments, as of June 2026:

Plan vCPU RAM Storage Bandwidth Price/mo
Regular Performance (IPv6 only) 1 0.5 GB 10 GB SSD 0.5 TB $2.50
High Performance AMD 1 1 GB 25 GB NVMe 2 TB $6.00
High Frequency (Intel Xeon) 1 1 GB 32 GB NVMe 1 TB $6.00
Optimized Cloud Compute GP 1 4 GB 30 GB NVMe 4 TB $30.00
VX1 General Purpose 2 8 GB 120 GB NVMe 5 TB ~$54/mo

Prices last checked: 2026-06-21 on the Vultr Pricing Page

Skip the $2.50 plan entirely: 0.5 GB RAM isn’t enough to run the Docker daemon plus any meaningful container. The $6/mo High Performance AMD is the right entry point for one or two lightweight containers (add swap; more on that below). For a real Docker Compose stack with a database, Nginx, and an app service, I’d go straight to the $12-$30 range to avoid headaches.

Every plan and its extra costs are listed in Vultr Pricing 2026.

Quick note on GPU instances: Don’t use this guide for Vultr Cloud GPU instances. Those come with Docker pre-installed alongside the NVIDIA Container Toolkit, and manually installing Docker on them can cause conflicts.

Step 1: Provision your Vultr VPS

Log in to the Vultr console, click Deploy Server, and configure:

  • Server type: Cloud Compute (High Performance)
  • Location: Pick the region closest to your users
  • OS: Ubuntu 24.04 LTS (what this guide targets)
  • Plan: $6/mo or higher (1 GB RAM minimum)
  • Additional Features: Enable “IPv6” if needed; optionally check “Backups” for peace of mind

Vultr also offers a Marketplace Docker App that auto-installs Docker at deploy time. It’s convenient for a quick test environment, but it installs a pinned Docker version you can’t easily update with apt upgrade. For anything running in production, the manual APT method below gives you full control.

Once deployed, SSH in as root:

ssh root@YOUR_SERVER_IP

Step 2: Install Docker Engine (the right way)

Don’t use apt install docker.io. Ubuntu’s default apt repository ships an outdated Docker version without Docker Compose V2. Don’t use snap either. It adds overhead and causes permission issues with bind mounts.

The correct method uses Docker’s official APT repository. Here’s the full sequence for Ubuntu 24.04:

Update the system and install dependencies

sudo apt update && sudo apt upgrade -y
sudo apt install apt-transport-https ca-certificates curl software-properties-common -y

Add Docker’s official GPG key

sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc

Add Docker’s APT repository

echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null

Install Docker Engine, CLI, and plugins

sudo apt update
sudo apt-get install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin -y

This one command installs everything you need: Docker Engine (docker-ce), the CLI (docker-ce-cli), the container runtime (containerd.io), multi-platform build support (docker-buildx-plugin), and Docker Compose V2 (docker-compose-plugin).

Enable Docker and verify

sudo systemctl enable docker
sudo systemctl status docker
sudo docker --version

Don’t skip systemctl enable docker. Without it, Docker won’t restart after a VPS reboot, a common gotcha that surfaces at the worst possible time.

Run Docker without sudo

sudo usermod -aG docker $USER
newgrp docker

Log out and back in for the group change to apply system-wide.

Step-by-step diagram showing Docker Engine installation on Vultr VPS Ubuntu 24.04 — 5 steps from apt update to docker --version verification with expected terminal output
Step-by-step diagram showing Docker Engine installation on Vultr VPS Ubuntu 24.04: 5 steps from apt update to docker –version verification with expected terminal output

Step 3: Add swap for 1 GB RAM plans

If you’re on the $6/mo plan (1 GB RAM), add a swap file before running containers. Without it, Docker can OOM-kill containers (or the daemon itself) under memory pressure.

Check first, though: on the stock Vultr Ubuntu 24.04 image we tested in September 2026, free -m already showed 2,399 MB of swap before we configured anything, so only create the swap file below if your Swap: line reads 0 (details in the next section).

# Create a 2 GB swap file
sudo fallocate -l 2G /swapfile

# Lock down permissions
sudo chmod 600 /swapfile

# Initialize and enable
sudo mkswap /swapfile
sudo swapon /swapfile

# Make it survive reboots
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab

# Reduce swappiness to protect NVMe write endurance
sudo sysctl vm.swappiness=10
echo 'vm.swappiness=10' | sudo tee -a /etc/sysctl.conf

# Verify
free -h

💡 Tip: Unsure how much swap your server needs? Our free Linux Swap Calculator recommends a size based on your RAM.

Setting vm.swappiness=10 tells the kernel to use swap only when RAM is above 90% capacity. Vultr’s NVMe handles swap well, but keeping swappiness low reduces unnecessary write wear on the SSD.

What we saw deploying Docker on a fresh $5 Vultr instance

The steps above come from Docker’s documentation and third-party benchmarks. To check them against a real server, we spun up a fresh Vultr instance on September 25, 2026 and installed Docker plus one real app container. The setup: Tokyo (nrt) region, the $5/mo Regular Cloud Compute plan (vc2-1c-1gb: 1 vCPU, 1 GB RAM, 25 GB SSD), Ubuntu 24.04. lscpu reported a single Intel Core (Broadwell) vCPU. That makes this a cheaper tier than the $6 High Performance AMD plan recommended above, and every number below comes from one run on one instance. It isn’t long-term monitoring.

Install: 42 seconds with the convenience script

One caveat up front: we installed with Docker’s convenience script (get.docker.com), not the step-by-step APT repository method from Step 2, and we didn’t time the manual method. The script installs the same docker-ce packages from Docker’s download.docker.com repository, but Docker’s docs don’t recommend it for production servers. That’s why Step 2 does the repository setup by hand. Here’s the exact command and its output:

$ export DEBIAN_FRONTEND=noninteractive; echo "== docker install (get.docker.com)"; start=$(date +%s); curl -fsSL https://get.docker.com | sh >/tmp/docker-install.log 2>&1; echo "exit=$? seconds=$(( $(date +%s)-start ))"; docker --version; free -m
== docker install (get.docker.com)
exit=0 seconds=42
Docker version 29.8.1, build 4a63305
               total        used        free      shared  buff/cache   available
Mem:             955         354         177           1         576         601
Swap:           2399           0        2399

Two lines in that free -m output matter for a small Docker host. First, the kernel sees 955 MB of RAM, not 1,024. With Docker installed and no containers running, 354 MB was already in use and 601 MB was available. Second, the stock Vultr image already had 2,399 MB of swap before we set anything up. That’s the safety net Step 3 tells you to build, so running Step 3 on this image as written would add a second 2 GB of swap on top of it. We only checked this one image on this one plan, so run free -m on yours before you create /swapfile.

Running a real app: Uptime Kuma 2

A hello-world container tells you nothing about sizing. So we ran Uptime Kuma 2 (app version 2.5.5), a self-hosted Node.js uptime monitor, and timed how long it took from docker run until it answered HTTP on the server itself:

$ echo "== uptime kuma 2 via docker"; start=$(date +%s)
docker run -d --restart=unless-stopped -p 3001:3001 -v uptime-kuma:/app/data --name uptime-kuma louislam/uptime-kuma:2 >/dev/null
until curl -s -o /dev/null -w "%{http_code}" http://localhost:3001 | grep -qE "200|302"; do sleep 3; done
echo "reachable after $(( $(date +%s)-start ))s (incl. image pull)"
docker image ls louislam/uptime-kuma --format "{{.Repository}}:{{.Tag}} {{.Size}}"
docker exec uptime-kuma node -e "console.log(\"app version\", require(\"/app/package.json\").version)"
docker stats --no-stream --format "idle: {{.Name}} mem={{.MemUsage}} cpu={{.CPUPerc}}"
== uptime kuma 2 via docker
reachable after 58s (incl. image pull)
louislam/uptime-kuma:2 2.49GB
app version 2.5.5
idle: uptime-kuma mem=184.3MiB / 955.7MiB cpu=0.03%

That’s 58 seconds, and it includes pulling a 2.49 GB image. Idle, the container used 184.3 MiB. After we added three monitors, we checked the container and the host again:

$ docker stats --no-stream --format "3 monitors @20s: mem={{.MemUsage}} cpu={{.CPUPerc}}"; free -m | head -2; df -h / | tail -1
3 monitors @20s: mem=196MiB / 955.7MiB cpu=0.82%
               total        used        free      shared  buff/cache   available
Mem:             955         518          85           0         537         436
/dev/vda2        23G  8.9G   13G  41% /

With Uptime Kuma running, the host used 518 of 955 MB and still had 436 MB available. On disk, df reports 23 GB for the root filesystem on this 25 GB plan, with 8.9 GB used and 13 GB free. That one 2.49 GB image takes up about 10% of the plan’s 25 GB by itself. (We ran -p 3001:3001 only for this quick test. On a server you keep, that published port goes around UFW as described in Step 5, so bind it to localhost behind a reverse proxy instead.)

We also checked memory limits on the same box. A container capped at --memory=128m that tried to allocate 300 MB was OOM-killed (exit code 137, OOMKilled=true), and the same allocation under --memory=512m completed. On disk speed, a 30-second fio 4K random-read run at queue depth 32 measured 59.7k IOPS on this plan’s SSD. That’s well below the ~118,000 combined IOPS quoted earlier, but that figure is Better Stack’s benchmark of the 2 vCPU / 4 GB High Performance plan, a different tier and a different test, so treat the two as separate data points rather than a contradiction.

Takeaways for sizing a $5, 1 GB Docker box

  • Check swap before you add it. Our stock image already had 2,399 MB. Run free -m first and only follow Step 3 if the Swap line reads 0.
  • Budget against 955 MB, not 1 GB. After Docker plus one Node.js app, 436 MB was still available. That’s room for another small service, but it’s tight for a database container too, which is why we point Compose stacks with a database at the $12 plan.
  • Images take more disk than you might expect. One app image was 2.49 GB, and with it the root filesystem sat at 8.9 of 23 GB. One more image that size gets you close to half the disk, so clear out unused images (docker image prune) as routine maintenance.
  • Set per-container memory limits. The 128 MB-capped test container was killed on its own with exit code 137, and the rest of our run carried on as normal.
  • Getting from a fresh box to a running app took about 100 seconds: 42 s for the install plus 58 s until the app responded. Provisioning and SSH aren’t included in that number.

Step 4: Docker Compose V2 commands and a real example

Docker Compose V2 is already installed from the docker-compose-plugin package above, so no separate download is needed. Verify it:

docker compose version

Note the syntax: docker compose (space, no hyphen). The old docker-compose hyphenated binary is deprecated and isn’t installed by default in 2026. If you see scripts using it, update them.

Key Docker Compose commands

Command What it does
docker compose up -d Start all services in the background
docker compose down Stop and remove containers and networks
docker compose stop Halt services (containers preserved)
docker compose ps Show running service status
docker compose logs -f Stream live logs
docker compose pull Update all images to latest

Real-world example: Nginx + Node.js app stack

Here’s a working compose.yaml (Docker Compose V2 prefers this name over docker-compose.yml, though both work):

version: "3.9"

services:
  nginx:
    image: nginx:latest
    container_name: nginx_proxy
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx/conf.d:/etc/nginx/conf.d:ro
      - ./certbot/www:/var/www/certbot:ro
    depends_on:
      - app
    restart: always

  app:
    image: node:20-alpine
    container_name: my_app
    working_dir: /usr/src/app
    volumes:
      - ./app:/usr/src/app
    expose:
      - "3000"
    environment:
      - NODE_ENV=production
    restart: always

networks:
  default:
    driver: bridge

The app service uses expose (not ports), so it’s only reachable internally from nginx, not directly from the internet. Nginx handles the public-facing traffic. Start the whole stack with:

docker compose up -d
Docker Compose architecture diagram on Vultr VPS — Nginx reverse proxy on ports 80/443 routing internal traffic to Node.js app container on port 3000 via Docker bridge network
Docker Compose architecture diagram on Vultr VPS: Nginx reverse proxy on ports 80/443 routing internal traffic to Node.js app container on port 3000 via Docker bridge network

Step 5: Fix the Docker UFW firewall bypass (critical)

This is the section most Docker-on-VPS guides skip, and it’s the one that matters most for security.

Here’s the problem: Docker manipulates iptables directly to expose container ports. When you publish a container with -p 80:80, Docker adds rules in the nat table that route traffic before it ever reaches UFW’s INPUT chain. The result: a container with -p 80:80 is publicly accessible from the internet even if UFW has no rule allowing port 80. UFW’s status output will say port 80 is blocked. The container is still reachable. This is by design, and Docker doesn’t plan to fix it.

To confirm a port is closed, test it from outside the server, not from ufw status.

Docker intentionally reserves the DOCKER-USER chain for sysadmin rules. Rules added here run before Docker’s own rules and survive Docker restarts:

# Drop all incoming traffic to Docker containers by default
sudo iptables -I DOCKER-USER -j DROP

# Allow traffic from your trusted IP or CIDR range
sudo iptables -I DOCKER-USER -s YOUR_IP_OR_CIDR -j ACCEPT

# Allow established and related connections (so responses get through)
sudo iptables -I DOCKER-USER -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT

# Check your rules
sudo iptables -L DOCKER-USER -n -v

💡 Tip: Prefer a visual approach? You can build these rules with our free Firewall Rule Builder. It generates ufw and iptables commands right in your browser.

Make these rules survive reboots:

sudo apt install -y iptables-persistent
sudo netfilter-persistent save

Fix option 2: Bind containers to localhost only

For services that shouldn’t be directly internet-facing (databases, internal APIs), bind to localhost instead of all interfaces:

# Instead of -p 80:80 (public), use:
docker run -d -p 127.0.0.1:8080:80 nginx

In compose.yaml:

ports:
  - "127.0.0.1:8080:80"

Then expose those services through a reverse proxy (Nginx or Caddy on the host) that you control explicitly. This approach is cleaner for multi-service setups.

Fix option 3: ufw-docker utility

If you prefer a tool that handles the integration automatically:

sudo wget -O /usr/local/bin/ufw-docker \
  https://github.com/chaifeng/ufw-docker/raw/master/ufw-docker
sudo chmod +x /usr/local/bin/ufw-docker
sudo ufw-docker install
sudo systemctl restart ufw

# Then selectively allow containers
sudo ufw-docker allow nginx 80

Basic UFW baseline (do this first)

Before any of the above fixes, set a sane UFW baseline:

sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow ssh
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable
sudo ufw status verbose
Diagram showing Docker UFW firewall bypass — Docker
Diagram showing Docker UFW firewall bypass: Docker’s nat table intercepting traffic before UFW INPUT chain, and the DOCKER-USER chain fix that enforces custom rules before Docker’s own forwarding logic

Common pitfalls at a glance

Pitfall Consequence Fix
apt install docker.io or snap Outdated Docker, no Compose V2 Use Docker’s official APT repo
docker-compose (hyphenated) Command not found (deprecated binary) Use docker compose (V2 plugin)
Docker bypasses UFW Containers publicly exposed despite firewall DOCKER-USER chain or localhost binding
1 GB RAM with no swap OOM kills daemon or containers Add 2 GB swap file
Skipping systemctl enable docker Docker stops after VPS reboot Always enable the service
Exposing all ports with -p X:X Accidental public exposure of internal services Use expose for internal services; ports only for public ones

Vultr + Docker is a strong combination

Vultr’s NVMe I/O and AMD EPYC compute make it one of the better-value platforms for Docker workloads, particularly if your stack involves a database container where the 118K IOPS figure matters. The setup itself isn’t complex, but three things will determine whether it works reliably in production: using Docker’s official APT repo (not docker.io), adding swap on 1 GB instances, and fixing the UFW bypass before you put anything sensitive behind a container.

For lightweight containerized apps, the $6/mo High Performance AMD plan is a credible starting point. In our one-off test on the even cheaper $5 Regular plan, Docker plus a real Node.js app (Uptime Kuma) still left 436 MB of RAM available and 13 GB of disk free. That’s enough headroom for one lightweight service, but not much more. For a full Compose stack (Nginx, app, and a database), go with the $12 or higher. New Vultr accounts currently get $300 in free credit (valid for 30 days), which gives you plenty of runway to test your setup before committing.

If you’re evaluating Vultr against other providers for Docker or VPS work generally, our cheap VPS roundup covers how it stacks up on price-to-performance across the sub-$10 tier.

New to Vultr? Sign up through our link and get up to $300 in promo credit (new customers, select products). Claim your credit →

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top