Install Docker and Docker Compose on an Ubuntu 24.04 VPS
To install Docker on an Ubuntu VPS, use Docker’s own apt repository rather than the docker.io package that ships with Ubuntu. You get a newer version, and Compose arrives as a ready-made plugin. The install itself is a handful of commands you can paste one after another, and each is explained below. The rest of the article covers what usually eats a beginner’s evening: a first Compose project that survives a reboot, ports that Docker opens straight through your firewall, container logs with no size limit, and a disk that fills up quietly.
We ran every command here on Ubuntu 24.04 (Docker 29.9.2 and Compose 5.6.1 as of 30 October 2026). Docker’s documentation lists the same procedure for Ubuntu 22.04 and 26.04, but we did not try those two.
Why run Docker on a VPS
Docker runs a program together with everything it needs inside an isolated box called a container. A container is created from an image, a ready-made template holding the program and its libraries. It has its own file system and its own library versions, so it does not fight its neighbours, and one command removes it without leaving debris on the system. That makes Docker a good home for software that is fiddly to install the usual way: n8n, Nextcloud, databases, bots.
Docker Compose sits on top of Docker. In one file you describe which containers you need, how they connect and where they keep data, then start the whole set with one command. Without it you would have to remember a long line with a dozen flags for every container.
Docker itself needs little memory. Disk goes faster: the n8n image weighs 1.81 GB and the Nextcloud image 2.13 GB, which you notice on a small disk. There is a section below on checking and cleaning used space.
What you need
- A VPS with 64-bit Ubuntu 22.04, 24.04 or 26.04. Any UFO.Hosting VPS plan will do. A modest configuration is enough for a couple of small containers; if you plan several services, take 2 GB of RAM or more.
- SSH access to the server.
sudorights or arootlogin. The commands below includesudo; drop it if you are root.
Install Docker from the official repository
A repository is a server that apt downloads packages from. First remove any old Docker packages. On a fresh server you can skip this step, and if some of the packages are missing, apt just says there is nothing to remove:
sudo apt remove docker.io docker-compose docker-compose-v2 docker-doc docker-buildx podman-docker containerd runc
Now connect Docker’s repository. The two packages we install first let you download a signing key safely over HTTPS. The key lets apt confirm that packages really come from Docker and were not swapped on the way:
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
install -m 0755 -d creates the key directory with the right permissions, and chmod a+r lets apt read the key. Next comes the repository definition itself. The command fills in your Ubuntu release and CPU architecture, so there is nothing to edit, and the Signed-By line ties the repository to the key you just downloaded:
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
If the apt update output contains a line with download.docker.com, the repository is connected. Now install Docker:
sudo apt install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
What each package does: docker-ce is Docker itself, a background service (often called the daemon). docker-ce-cli is the docker command you type in the terminal. containerd.io is the engine that actually runs containers. docker-compose-plugin is Compose. docker-buildx-plugin is for building your own images and is not needed here. After installation the docker service is already enabled at boot and running:
systemctl is-enabled docker
enabled
systemctl is-active docker
active
docker --version
Docker version 29.9.2, build a82e528
docker compose version
Docker Compose version v5.6.1
Write docker compose with a space. The hyphenated docker-compose is the old standalone version 1 tool, and its support ended in 2023. If a tutorial tells you to install that one, find another tutorial.
The last check is to run a container whose only job is to say that everything works:
sudo docker run --rm hello-world
...
Hello from Docker!
This message shows that your installation appears to be working correctly.
Docker pulled the image, started a container, got its text and deleted the container (that is what --rm does). The image itself stays on disk and will be reused next time.
Docker without sudo, and why that is risky
Run docker ps as a regular user and you get a refusal:
permission denied while trying to connect to the docker API at unix:///var/run/docker.sock
The fix is to add your user to the docker group: sudo usermod -aG docker your_user. The -aG flag appends the user to the group without removing them from others. Then log out and back in over SSH, because groups are read only at login. Do this deliberately: a member of the docker group is effectively root. They can start a container, mount the server’s entire file system into it and change anything. On your own server that is fine. Handing the group to other people or programs deserves the same caution as handing out the root password.
Your first Compose project
Take the simplest case: nginx serving one page from a folder. Create the project directory and the page:
mkdir -p ~/hello/site && cd ~/hello
echo '<!doctype html><meta charset="utf-8"><h1>Привет из контейнера</h1>' > site/index.html
The heading text is Russian (“Hello from the container”). It is only a test string, so use any text you like. Now the compose.yaml file; create it in any editor, for example nano compose.yaml. This is the current name for a Compose file, and the older docker-compose.yml still works too.
services:
web:
image: nginx:1.28-alpine
restart: unless-stopped
ports:
- "127.0.0.1:8080:80"
volumes:
- ./site:/usr/share/nginx/html:ro
Line by line:
servicesis the list of the project’s containers. We have one, calledweb.image: nginx:1.28-alpinesays which image to build it from. The part after the colon is a tag, a version label. Pin it explicitly: withlatest, an ordinary update can silently bring a new major version with breaking changes.alpinemeans a slimmed-down build of the image.restart: unless-stoppedbrings the container back if it crashes or the server reboots, unless you stopped it yourself. Without this line the container will not start on its own after a reboot.portsforwards a server port into the container.127.0.0.1:8080:80means that port 80 inside the container is reachable only from the server itself, on port 8080. Why not plain8080:80is explained in the firewall section below.volumesshows a server folder to the container. Our page lives in./site, and nginx looks for files in/usr/share/nginx/html. The:rosuffix makes it read-only. The same mechanism mounts plain folders and named volumes, which are data storage outside the container that survives when the container is deleted. We will meet named volumes in the next articles of this series.
Check the file, then start the project:
docker compose config --quiet && echo OK
OK
docker compose up -d
Container hello-web-1 Created
Container hello-web-1 Started
The -d flag keeps the container running in the background. Without it Compose holds your terminal and stops everything when you close the window. config --quiet starts nothing; it only validates the file and stays silent if there are no errors.
docker compose ps
NAME IMAGE COMMAND SERVICE CREATED STATUS PORTS
hello-web-1 nginx:1.28-alpine "/docker-entrypoint.…" web 2 seconds ago Up 2 seconds 127.0.0.1:8080->80/tcp
curl -s localhost:8080
<!doctype html><meta charset="utf-8"><h1>Привет из контейнера</h1>
The page responds. You cannot open it from a browser on your own computer, because the port is bound to 127.0.0.1, so we test from the server itself. Where does the name hello-web-1 come from? Compose builds it from the project folder (hello), the service name (web) and an instance number. That makes the folder name matter: two projects in identically named folders will collide.
Reboot, update, stop
To confirm that restart: unless-stopped works, reboot the server (sudo reboot), log back in and run docker compose ps in the project folder. The container should be Up again. The Docker service itself starts at boot, as the is-enabled check above showed.
There are only a few everyday commands. Run them in the folder that holds compose.yaml:
| Command | What it does |
|---|---|
docker compose ps |
Lists the project’s containers and their status. |
docker compose logs -f web |
Prints the web container’s log and follows new lines. Exit with Ctrl+C. |
docker compose restart web |
Restarts one container. |
docker compose exec web sh |
Opens a shell inside the running container. |
docker compose pull, then docker compose up -d |
Update: first download fresh images, then recreate only the containers whose image changed. The rest are left alone. |
docker compose down |
Stops and removes the project’s containers and network. Images and data in volumes stay. |
down has a dangerous variant. A plain docker compose down leaves a named data volume in place, while docker compose down -v deletes it too. We tested this on a throwaway volume: after down the volume was still listed, after down -v it was gone. If a database lived there, you have nothing to restore it from. Add -v only when you really want to start from zero.
For more Docker commands beyond Compose, see the guide to essential Docker commands.
Firewall and ports: what Docker does that you don’t expect
Even an experienced admin can get caught here. Say you enabled ufw and allowed only SSH. Logic says everything else is closed from outside. With Docker it is not: when a container publishes a port, Docker adds its own rules to iptables, the kernel’s packet filter, and they fire before ufw‘s rules. Docker’s documentation says it plainly: traffic to published ports bypasses ufw settings.
We checked this on a server. ufw allows only port 22:
sudo ufw status
Status: active
To Action From
-- ------ ----
22/tcp ALLOW Anywhere
22/tcp (v6) ALLOW Anywhere (v6)
An nginx container is published as "80:80". A request from another server (the -w flag prints the response code, and 000 means no connection was made at all):
curl -s -o /dev/null -w "http=%{http_code}\n" http://YOUR_SERVER_IP/
http=200
Port 80 is not allowed in ufw, yet the site is open to the whole internet. Now the same container, but with the port written in compose.yaml as "127.0.0.1:80:80":
curl -s -o /dev/null -w "http=%{http_code}\n" http://YOUR_SERVER_IP/
http=000
From outside the port is closed, while on the server itself curl http://127.0.0.1/ returns 200. We used port 80 for this test because on our test server non-standard ports such as 8080 were not reachable from outside anyway (a network filter blocked them), which muddied the picture. Your filter may differ; the ufw bypass itself does not depend on it. In the command, YOUR_SERVER_IP stands for your server’s IP address.
The rule that follows: publish any port that should not be visible from the internet (a database, an admin panel, an internal service) only on 127.0.0.1. Whatever must be public goes through a reverse proxy, a program that accepts requests from the internet on ports 80 and 443 and passes them to the right containers. That is how the next articles in the series are built, for n8n and Nextcloud.
A caveat on versions. According to Docker’s documentation, before version 28.0.0 a port published on 127.0.0.1 could be reached by other machines on the same network segment. Docker 29, which this article installs, does not have that problem. If you run docker.io from Ubuntu, check your version with docker --version.
You can also write your own rules for containers: Docker’s documentation provides the DOCKER-USER chain for that. Binding to 127.0.0.1 is simpler and more reliable, so start there. Still set up ufw: it keeps protecting the server itself, just not the containers.
Logs: set up rotation now
By default Docker writes each container’s log to a file and never trims it. A busy service collects gigabytes within a few months. Here is how to see the current setting:
docker info --format '{{.LoggingDriver}}'
json-file
The json-file driver with no size limit is a poor choice on a small disk. Create /etc/docker/daemon.json (it does not exist by default), for example with sudo nano /etc/docker/daemon.json:
{
"log-driver": "local",
"log-opts": { "max-size": "10m", "max-file": "3" }
}
The local driver stores logs in a compact format, and max-size with max-file cap them at three files of 10 MB each. Apply it:
sudo systemctl restart docker
docker compose up -d --force-recreate
After the restart the setting applies to new containers only. Existing ones keep the old one, which is why we recreate them with --force-recreate (run it in the project folder). Check the result:
docker inspect -f '{{json .HostConfig.LogConfig}}' hello-web-1
{"Type":"local","Config":{"max-file":"3","max-size":"10m"}}
One catch: files in the local format cannot be read by hand. That is not a problem, because docker compose logs works as usual.
Disk space
Images, stopped containers and volumes pile up. To see what takes the space:
docker system df
TYPE TOTAL ACTIVE SIZE RECLAIMABLE
Images 2 1 93.42MB 25.87kB (0%)
Containers 1 1 81.92kB 0B (0%)
Local Volumes 0 0 0B 0B
Build Cache 0 0 0B 0B
The RECLAIMABLE column shows how much you can free. Cleaning options:
docker system pruneremoves stopped containers, unused networks and dangling images (untagged leftovers). It asks for confirmation and leaves running containers and volumes alone. It will still delete a stopped container you meant to start again, so read the list before agreeing.docker system prune -aadditionally removes every image that no container uses. Anything you need is downloaded again later.- The
--volumesflag adds removal of volumes that no container references, which means data. Use it only when you are sure those volumes hold nothing valuable.
When something goes wrong
Port already in use. The error text depends on who holds the port. If it is another container, Docker says:
docker: Error response from daemon: failed to set up container networking: driver failed programming external connectivity on endpoint clash (...): Bind for 127.0.0.1:80 failed: port is already allocated
If a program on the server itself holds the port, such as nginx or Apache, the answer differs:
docker: Error response from daemon: failed to set up container networking: driver failed programming external connectivity on endpoint clash2 (...): failed to bind host port 0.0.0.0:8099/tcp: address already in use
To find out who has the port, run sudo ss -tlnp | grep ':80 '. It lists the programs listening on TCP ports. Then stop the offender or pick another port.
The version warning. Many older tutorials start compose.yaml with version: "3.8". Current Compose reports it like this:
level=warning msg="/opt/vol/compose.yaml: the attribute `version` is obsolete, it will be ignored, please remove it to avoid potential confusion"
This is not an error. Delete the line; it has no effect on how things run.
If Docker answers “permission denied”, go back to the section on sudo and the docker group.
FAQ
Do I need the separate docker-compose package?
No. Compose installs as a plugin together with Docker (the docker-compose-plugin package) and runs as docker compose with a space.
Can I install Docker with apt install docker.io?
You can, and it works. On Ubuntu 24.04 it gives Docker 29.1.3 against 29.9.2 from Docker’s repository, and Compose comes as a separate docker-compose-v2 package at version 2.40.3 (Docker’s repository currently has 5.6.1). On a practice server you will not feel the difference, but updates reach you later. We use Docker’s repository so that Docker and Compose update together with one apt upgrade.
A container crashed. What do I look at?
First docker compose ps -a to see the status, then docker compose logs --tail 50 service_name to read the last log lines. The cause is almost always at the end.
Key points and what next
- Install Docker from its own repository and run Compose as
docker compose, with a space. - Add
restart: unless-stoppedand pin image tags instead of usinglatest. - Published ports bypass
ufw. Bind anything private to127.0.0.1. - Limit log size in
daemon.jsonand checkdocker system dfnow and then.
With Docker running and ports under control, you can start ready-made services on it. The next articles in the series install n8n with HTTPS and autostart, and a private cloud on Nextcloud. Include the containers’ data in your server backups from the start.
Related
All articles
Nextcloud on a VPS: your own cloud with Docker Compose
Nextcloud is a cloud you own: files synced to your phone and computer, a calendar, contacts, and share-by-link. On a VPS you run it with Docker Compose as five containers: Nextcloud itself, a PostgreSQL database, a Redis cache, a container…
How to Install n8n on a VPS with Docker Compose
You can run n8n on your own VPS with Docker Compose in a single evening: two containers (n8n itself and Caddy, a reverse proxy that issues the HTTPS certificate for you), three small files and one start command. Below you…