Docker on a VPS: getting started
Docker lets you run a bot, a website, a database and monitoring on one server without them getting in each other's way, and moving to another VPS comes down to copying a couple of files and the data. Installation takes five minutes; the real trouble starts later: database ports exposed to the internet, logs growing to tens of gigabytes and data that vanished along with a container. Here is how to avoid all of that from the start.
A container is an application packaged into an image together with all its libraries and settings. On a server it starts with one command and behaves the same on any Linux. For a VPS this means you do not have to match Python, Node.js or PostgreSQL versions to the system, several projects can share one server, and a compose.yaml file describes the whole set of services so that it can be brought up on a new server in minutes.
What kind of VPS Docker needs
Virtualization: KVM. Docker relies on Linux kernel features: namespaces, cgroups and its own network rules. On KVM the server has its own kernel, and Docker works without restrictions. On OpenVZ and LXC the kernel is shared with other customers, and Docker either does not start at all or runs with restrictions that depend on the host. More in KVM or OpenVZ.
Memory and cores. Docker itself is light; it is the containers that eat memory. Guidelines:
- 1 GB — one or two light containers: a bot, a small API, monitoring;
- 2 cores and 2 GB — a website with a database and a reverse proxy, a few small services;
- 2–4 cores and 4 GB — several projects, PostgreSQL, Redis, building images right on the server;
- 8 GB or more — GitLab, n8n with many workflows, Elasticsearch and other heavy services.
Disk. Images take up space: a base image with Python or Node.js weighs hundreds of megabytes, and old versions pile up with every update. Take at least 30 GB on NVMe, and more if you build images on the server.
According to the accsly catalog as of 1 November 2026, a KVM plan with 2 cores and 2 GB of memory costs about $15 a month at the middle of the market, and a quarter of such plans are cheaper than about $9. With 2 cores and 4 GB the median is about $20, with a quarter cheaper than about $12 (the catalog stores prices in roubles; dollar figures are rounded). Selections: VPS for Docker, KVM VPS, VPS with 4 GB. For experiments, hourly billing is handy: a server for an evening costs pocket change.
Before installing
Take a fresh Ubuntu 22.04 or 24.04 LTS or Debian 12 and do the basic hardening first: updates, key-based sign-in, a separate user, a firewall. All of it is in The first 10 steps after buying a VPS. A hosting control panel is usually unnecessary on a container server; why is explained in Server control panels.
Installing from the official repository
The docker.io package from the Ubuntu repository often lags behind the current version and does not always include Compose. Installing from Docker's own repository is more reliable. The commands for Ubuntu, following the official documentation:
sudo apt update
sudo apt install ca-certificates curl
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 <<EOF
Types: deb
URIs: https://download.docker.com/linux/ubuntu
Suites: $(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}")
Components: stable
Architectures: $(dpkg --print-architecture)
Signed-By: /etc/apt/keyrings/docker.asc
EOF
sudo apt update
sudo apt install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
For Debian the commands are the same, except that linux/ubuntu in the URLs becomes linux/debian, and VERSION_CODENAME is used instead of UBUNTU_CODENAME. Check the installation:
sudo docker run hello-world
docker compose version
There is also a one-command installer at get.docker.com, but Docker's own documentation advises against it for production servers: it installs many packages without asking and always takes the latest version.
The docker group. To avoid typing sudo before every command, the user is added to the docker group: sudo usermod -aG docker $USER, then sign in again. Keep in mind that this equals root rights: a member of the group can start a container with access to the server's entire file system. Add only your own working account to it.
A first Docker Compose project
Separate docker run commands with a dozen options are quickly forgotten. It is more convenient to describe the project in a compose.yaml file in its own directory, for example /opt/app. Here is a web application with a PostgreSQL database:
services:
app:
image: ghcr.io/example/app:1.4.2
restart: unless-stopped
environment:
DATABASE_URL: postgres://app:${DB_PASSWORD}@db:5432/app
ports:
- "127.0.0.1:8080:8080"
depends_on:
- db
db:
image: postgres:16
restart: unless-stopped
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- db-data:/var/lib/postgresql/data
volumes:
db-data:
Put the password next to it in a .env file with the line DB_PASSWORD=... and make it unreadable for other users: chmod 600 .env. Start with docker compose up -d, follow the log with docker compose logs -f app, stop with docker compose down.
Three things in this example matter:
restart: unless-stopped— the containers come back up after a server reboot;- the database has no
portssection: the application reaches it by the namedbon the project's internal network, and it is not reachable from outside; - the application port is published only on
127.0.0.1, and nginx or Caddy with a TLS certificate serves it to the outside world.
Ports and the firewall
This is the main trap for beginners. When a container publishes a port as "5432:5432", Docker adds iptables rules itself, and that port is opened to the internet regardless of your ufw or firewalld rules. Docker's documentation warns about this directly: published container ports are not subject to the firewall rules. This is exactly how PostgreSQL, MongoDB and Redis databases without a password most often end up on the internet.
The rule is simple: publish externally only what should be reachable by everyone — usually ports 80 and 443 of the reverse proxy. Everything else goes either without a ports section or bound to 127.0.0.1. You can check what the server is actually listening on with ss -tlnp, and from outside, from another computer, with nc -zv server-IP 5432.
Logs and disk space
By default Docker writes container output to JSON files with no size limit. A chatty application fills the disk in a couple of months, and the server stops working. Limit logs for all containers in /etc/docker/daemon.json:
{
"log-driver": "json-file",
"log-opts": {
"max-size": "10m",
"max-file": "3"
}
}
Then run sudo systemctl restart docker. The setting applies to containers created after it, so recreate your projects: docker compose up -d --force-recreate.
The second disk hog is old images. docker system df shows how much space is used, and docker image prune -a removes unused images. Do not run docker system prune --volumes unless you understand it: it also deletes the volumes of stopped projects along with their data.
Where data lives and how to back it up
Anything the application wrote inside a container disappears when the container is recreated. Only what lives in volumes (volumes) or in mounted server directories survives. Before running any image, check its documentation for which directories need to go into a volume: for PostgreSQL it is /var/lib/postgresql/data, other applications have their own paths.
Copying database files while the database is running is unreliable. Take a dump through the container itself:
docker compose exec -T db pg_dump -U app app | gzip > /backup/app-$(date +%F).sql.gz
Add this command to cron and send the copies off the server, together with compose.yaml and .env. How to set up external storage and test the copies is covered in VPS backups.
Updating containers
Pin a specific image version in compose.yaml (postgres:16, app:1.4.2) rather than latest: then an update happens when you plan it, not on a random restart. The update procedure:
- take a database dump;
- change the version in
compose.yaml; docker compose pullanddocker compose up -d;- check the log and that the application works, then remove the old image.
Major database versions, for example PostgreSQL 16 → 17, cannot be updated this way: the new version needs the data moved over with a dump and restore. Update the server's system and Docker itself with the usual apt upgrade.
Where to go next
Once a server hosts several projects, it is worth adding a reverse proxy that obtains certificates on its own, plus uptime monitoring. A bot in Python or Node.js in a container needs only a small server; details in VPS for a Telegram bot. If containers multiply and one server is no longer enough, the next step is several servers under an orchestrator; plans are in VPS for Kubernetes. For automation and CI in containers there are the VPS for n8n and VPS for GitLab selections.