LINESERVE
All blog

Install Docker and Compose on an Ubuntu VPS Without Leaving the Port Open

Docker Engine and Compose on Ubuntu 24.04, from which repository to use to a container that survives a reboot — including the published port a ufw deny rule does not cover, and the one-line fix.

ArticleStephen NdegwaPublished Last updated 13 min read
Share

Use Docker’s own apt repository, not Ubuntu’s. The whole install is eight commands, if you want them in one block:

sudo apt-get update
sudo apt-get install -y 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
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
sudo apt-get update
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin

Then two things catch nearly everyone. docker ps fails the first time you run it. And the port you publish stays reachable after you turn on ufw and tell it to deny everything inbound.

Every command below was run on a clean Ubuntu 24.04.4 install and printed exactly what is shown under it.

Before you start

A server on Ubuntu 24.04 LTS you can reach over SSH, an account on it with sudo, and outbound HTTPS to download.docker.com. Everything else gets installed below.

Five values in the commands are yours:

Value Example used below What it is
your login account ubuntu what you SSH in as; $USER expands to it
project directory ~/site where the Compose file lives
published port 8080 the host port your application answers on
container image nginx:1.29-alpine stand-in for whatever you are actually running
your server’s address 203.0.113.10 what hostname -I prints on it

Ubuntu’s Docker packages, or Docker’s own

Ubuntu ships Docker itself. Look at what is on offer before adding anything:

apt-cache policy docker.io docker-compose docker-compose-v2
docker.io:
  Installed: (none)
  Candidate: 29.1.3-0ubuntu3~24.04.2
  Version table:
     29.1.3-0ubuntu3~24.04.2 500
        500 http://archive.ubuntu.com/ubuntu noble-updates/universe amd64 Packages
        500 http://security.ubuntu.com/ubuntu noble-security/universe amd64 Packages
     24.0.7-0ubuntu4 500
        500 http://archive.ubuntu.com/ubuntu noble/universe amd64 Packages
docker-compose:
  Installed: (none)
  Candidate: 1.29.2-6ubuntu1
  Version table:
     1.29.2-6ubuntu1 500
        500 http://archive.ubuntu.com/ubuntu noble/universe amd64 Packages
docker-compose-v2:
  Installed: (none)
  Candidate: 2.40.3+ds1-0ubuntu1~24.04.1
  Version table:
     2.40.3+ds1-0ubuntu1~24.04.1 500
        500 http://archive.ubuntu.com/ubuntu noble-updates/universe amd64 Packages
     2.24.6+ds1-0ubuntu2 500
        500 http://archive.ubuntu.com/ubuntu noble/universe amd64 Packages

Three packages, and the middle one is the trap. docker-compose at 1.29.2 is the old standalone tool, invoked with a hyphen as docker-compose up. docker-compose-v2 at 2.40.3 is the current plugin, invoked with a space as docker compose up. Anyone working from an older guide that says apt install docker-compose gets the first, and then loses an afternoon to a Compose file the tool cannot parse.

The other received wisdom — that Ubuntu’s docker.io is hopelessly stale — does not survive that output either. With noble-updates enabled it offers 29.1.3 against Docker’s 29.6.2. The gap is a patch series, not an era.

Install from Docker’s repository anyway, for support rather than for versions. Docker’s Ubuntu install documentation treats the distribution’s docker.io, docker-compose and docker-compose-v2 as unofficial packages and requires you to uninstall them before the official ones go on. Running a mix is a configuration nobody upstream will help you with.

Those Installed: (none) lines are the check. If any of yours names a version instead, take it off before going further:

sudo apt-get remove -y docker.io docker-compose docker-compose-v2
Reading package lists...
Building dependency tree...
Reading state information...
Package 'docker.io' is not installed, so not removed
Package 'docker-compose' is not installed, so not removed
Package 'docker-compose-v2' is not installed, so not removed
0 upgraded, 0 newly installed, 0 to remove and 31 not upgraded.

Three “not installed, so not removed” lines is the clean case.

Add the repository

Two packages let apt talk to an HTTPS repository at all:

sudo apt-get update
sudo apt-get install -y ca-certificates curl

On a stock image both are already present:

Reading package lists...
Building dependency tree...
Reading state information...
ca-certificates is already the newest version (20260601~24.04.1).
ca-certificates set to manually installed.
curl is already the newest version (8.5.0-2ubuntu10.11).
curl set to manually installed.
0 upgraded, 0 newly installed, 0 to remove and 31 not upgraded.

Next the keyring directory, Docker’s signing key, and read permission on it. All three print nothing:

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

Apt drops to the unprivileged _apt user when it verifies a release file, and a key only root can read fails that step with an error that does not mention permissions.

Write the repository line, which lands with your architecture and codename already substituted in:

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

Read it back before trusting it — a wrong codename here surfaces as a 404 two commands later rather than as an obvious mistake:

cat /etc/apt/sources.list.d/docker.list
deb [arch=amd64 signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu noble stable

noble is 24.04’s codename, and the line reads arm64 on an Arm server.

Refresh the package lists:

sudo apt-get update
Hit:1 http://security.ubuntu.com/ubuntu noble-security InRelease
Get:2 https://download.docker.com/linux/ubuntu noble InRelease [48.5 kB]
Hit:3 http://archive.ubuntu.com/ubuntu noble InRelease
Hit:4 http://archive.ubuntu.com/ubuntu noble-updates InRelease
Get:5 https://download.docker.com/linux/ubuntu noble/stable amd64 Packages [61.6 kB]
Hit:6 http://archive.ubuntu.com/ubuntu noble-backports InRelease
Fetched 110 kB in 1s (195 kB/s)
Reading package lists...

The two download.docker.com lines are the ones that matter. If they are missing, the repository file is wrong. If apt objects to a signature instead, the key or its permissions are.

Install

Install the engine, the CLI, containerd and both plugins together:

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

That pulls about 99 MB of archives and settles at roughly 380 MB on disk, scrolling a long apt unpack log past. The checks are the three commands after it, not the log. Check both binaries:

docker --version
Docker version 29.6.2, build dfc4efb
docker compose version
Docker Compose version v5.3.1

docker-compose: command not found on a perfectly good install is that v1/v2 split showing up as an error.

And confirm it will come back on its own:

systemctl is-enabled docker
enabled

disabled here is why people occasionally find their whole stack down after an unplanned reboot, with nothing in the application logs to explain it.

Why docker ps fails the first time

Run it now and it does not work:

docker ps
permission denied while trying to connect to the docker API at unix:///var/run/docker.sock

Nothing is broken. The daemon listens on a Unix socket owned by the docker group and your account is not in it. Add it:

sudo usermod -aG docker $USER

It prints nothing. Then log out and log back in. Group membership is fixed when a session starts, so the shell you are sitting in will keep failing however many times you retry. Reconnect and check:

id -nG
ubuntu adm cdrom sudo dip lxd docker

docker on the end is what you are after. Be deliberate about who else you add: membership of that group is equivalent to root on the machine, because anyone in it can start a container that mounts the host filesystem and write anywhere. That is an argument for keeping the list short, not for prefixing everything with sudo — which hands over exactly the same access by a longer route.

Now the whole path works:

docker run --rm hello-world

Hello from Docker!
This message shows that your installation appears to be working correctly.

To generate this message, Docker took the following steps:
 1. The Docker client contacted the Docker daemon.
 2. The Docker daemon pulled the "hello-world" image from the Docker Hub.
    (amd64)
 3. The Docker daemon created a new container from that image which runs the
    executable that produces the output you are currently reading.
 4. The Docker daemon streamed that output to the Docker client, which sent it
    to your terminal.

To try something more ambitious, you can run an Ubuntu container with:
 $ docker run -it ubuntu bash

Share images, automate workflows, and more with a free Docker ID:
 https://hub.docker.com/

For more examples and ideas, visit:
 https://docs.docker.com/get-started/

Pull progress appears above that on the first run, since the image is not local yet. --rm removes the container as it exits, so nothing accumulates.

Cap the logs before you run anything real

The default json-file log driver has no size limit. A container that writes steadily to stdout will fill the disk, and on a 40 GB server that is a matter of weeks.

/etc/docker/daemon.json holds daemon-wide defaults and does not exist yet on a fresh install. Its contents:

{
  "log-driver": "json-file",
  "log-opts": {
    "max-size": "10m",
    "max-file": "3"
  }
}

Three files of 10 MB each, rotated, per container — 30 MB and a hard ceiling. Docker’s json-file driver reference has the rest of the options if you want a different shape.

Write it in your editor, or paste this:

printf '%s\n' '{' '  "log-driver": "json-file",' '  "log-opts": {' '    "max-size": "10m",' '    "max-file": "3"' '  }' '}' | sudo tee /etc/docker/daemon.json > /dev/null

Then check it parses. A malformed daemon.json stops the daemon from starting at all, and systemctl restart docker is a bad place to find that out:

sudo dockerd --validate --config-file /etc/docker/daemon.json
configuration OK

Now pick it up, and confirm the daemon came back rather than assuming it:

sudo systemctl restart docker

The restart itself is silent, so ask:

systemctl is-active docker
active

Run something with Compose

A Compose file describes what should be running, so that a reboot or a redeploy is not a memory exercise. Create the directory and the file in one go:

mkdir -p ~/site && printf '%s\n' 'services:' '  web:' '    image: nginx:1.29-alpine' '    restart: unless-stopped' '    ports:' '      - "8080:80"' > ~/site/compose.yaml

That writes ~/site/compose.yaml:

services:
  web:
    image: nginx:1.29-alpine
    restart: unless-stopped
    ports:
      - "8080:80"

restart: unless-stopped brings the container back after a reboot or a daemon crash but leaves it down if you stopped it deliberately. ports maps host port 8080 to port 80 inside the container. Both are service-level keys; swap the image and the ports for your own application and the shape does not change.

Start it:

cd ~/site && docker compose up -d

It pulls the image and prints a progress line per layer, ending with the container started. Compose derives the project name from the directory, and names the container for the project, the service and an index — site plus web plus 1 gives site-web-1, which is what every docker command below wants. Ask the kernel what is listening:

ss -ltn 'sport = :8080'
State  Recv-Q Send-Q Local Address:Port Peer Address:PortProcess
LISTEN 0      4096         0.0.0.0:8080      0.0.0.0:*          
LISTEN 0      4096            [::]:8080         [::]:*          

0.0.0.0 and [::] mean every address the machine holds, public ones included. The site answers:

curl -sI http://127.0.0.1:8080 | head -1
HTTP/1.1 200 OK

The firewall is on and the port still answers

Ubuntu’s server images carry ufw; if yours answers ufw: command not found, sudo apt-get install -y ufw first.

Allow SSH before you turn the firewall on, or you will lock yourself out of the server. The default policy denies everything inbound, and your own session is inbound.

sudo ufw allow OpenSSH
Rules updated
Rules updated (v6)

Do not reach for sudo ufw status to confirm that. Until the firewall is enabled it answers with one line and no rules at all:

sudo ufw status verbose
Status: inactive

ufw show added echoes the rule back, so its second line is a copy of the rule rather than a command for you to run:

sudo ufw show added
Added user rules (see 'ufw status' for running firewall):
ufw allow OpenSSH

Now turn it on:

sudo ufw --force enable
Firewall is active and enabled on system startup

And now status has something to say:

sudo ufw status verbose
Status: active
Logging: on (low)
Default: deny (incoming), allow (outgoing), deny (routed)
New profiles: skip

To                         Action      From
--                         ------      ----
22/tcp (OpenSSH)           ALLOW IN    Anywhere                  
22/tcp (OpenSSH (v6))      ALLOW IN    Anywhere (v6)             

Deny incoming, deny routed, one rule allowing 22, and nothing anywhere permitting 8080. On any ordinary reading of that, the port is shut. Ask the machine for it on its own routable address rather than on loopback:

curl -s --max-time 5 -o /dev/null -w '%{http_code}\n' http://$(hostname -I | awk '{print $1}'):8080
200

Take that for what it is: the port answering on a non-loopback address with a firewall in front of it that denies incoming. It is not the whole answer, because a packet that starts on the server and a packet that arrives from outside take different paths through the kernel. Run the same request from a second machine — curl --max-time 5 http://203.0.113.10:8080, with your server’s address in place of the example — and that is the version that settles it.

Either way the mechanism is not in doubt, and it is visible in the packet filter. Look at the chain that governs forwarded traffic:

sudo iptables -S FORWARD
-P FORWARD DROP
-A FORWARD -j DOCKER-USER
-A FORWARD -j DOCKER-FORWARD
-A FORWARD -j ufw-before-logging-forward
-A FORWARD -j ufw-before-forward
-A FORWARD -j ufw-after-forward
-A FORWARD -j ufw-after-logging-forward
-A FORWARD -j ufw-reject-forward
-A FORWARD -j ufw-track-forward

Docker’s two chains are jumped to before any ufw chain is reached, and Docker documents the rest of it plainly in its packet filtering and firewalls guide: publishing a container’s port diverts traffic in the nat table before it reaches the INPUT and OUTPUT chains ufw uses, “effectively ignoring your firewall configuration”.

So ufw status is telling the truth about ufw. It is simply not on that path, and adding more ufw rules will not put it there.

Publish to loopback instead

The fix is one address in front of the port mapping:

printf '%s\n' 'services:' '  web:' '    image: nginx:1.29-alpine' '    restart: unless-stopped' '    ports:' '      - "127.0.0.1:8080:80"' > ~/site/compose.yaml

Recreate the container so the mapping takes effect:

cd ~/site && docker compose up -d
 Container site-web-1 Recreate 
 Container site-web-1 Recreated 
 Container site-web-1 Starting 
 Container site-web-1 Started 

The socket has moved:

ss -ltn 'sport = :8080'
State  Recv-Q Send-Q Local Address:Port Peer Address:PortProcess
LISTEN 0      4096       127.0.0.1:8080      0.0.0.0:*          

One line where there were three, bound to loopback, no IPv6 wildcard. The request that came back 200 a moment ago now goes nowhere:

curl -s --max-time 5 -o /dev/null -w '%{http_code}\n' http://$(hostname -I | awk '{print $1}'):8080
000

000 and a non-zero exit is curl saying it never opened a connection at all. Run the off-host request again too; it should fail the same way. Meanwhile nothing about the application changed:

curl -sI http://127.0.0.1:8080 | head -1
HTTP/1.1 200 OK

Application containers publish to 127.0.0.1; one reverse proxy — Nginx or Caddy on the host, terminating TLS — is the only thing on a public interface, and it reaches the containers over loopback. ufw then guards 80 and 443, which are ordinary host sockets it does control.

Publish to 0.0.0.0 only when you mean that service to be on the internet. If it has to be reachable from a restricted set of addresses instead, the rule belongs in the DOCKER-USER chain, which Docker keeps as a placeholder for user-defined rules processed ahead of its own.

Confirm it survives a reboot

The daemon’s boot setting, the restart policy, the log cap and the firewall all live in files, and the only way to know they hold together is to take the machine down:

sudo systemctl reboot

Wait for it to come back, reconnect, and look:

docker ps --format 'table {{.Names}}\t{{.Status}}'
NAMES        STATUS
site-web-1   Up About a minute

Nobody started that. It is also serving:

curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8080
200

The firewall came back with it:

sudo ufw status verbose
Status: active
Logging: on (low)
Default: deny (incoming), allow (outgoing), deny (routed)
New profiles: skip

To                         Action      From
--                         ------      ----
22/tcp (OpenSSH)           ALLOW IN    Anywhere                  
22/tcp (OpenSSH (v6))      ALLOW IN    Anywhere (v6)             

Two numbers worth watching

The log cap only counts if it reached the container. Ask the container what it got:

docker inspect --format '{{json .HostConfig.LogConfig}}' site-web-1
{"Type":"json-file","Config":{"max-file":"3","max-size":"10m"}}

A container created before the daemon restart comes back empty here; docker compose up -d --force-recreate fixes it.

The other number is what Docker is holding on disk:

docker system df
TYPE            TOTAL     ACTIVE    SIZE      RECLAIMABLE
Images          2         1         93.52MB   25.87kB (0%)
Containers      1         1         81.92kB   0B (0%)
Local Volumes   0         0         0B        0B
Build Cache     0         0         0B        0B

One image and one container is nothing, and it does not stay that way: every rebuild leaves a dangling image and every docker compose pull leaves the one it replaced. RECLAIMABLE is the column to watch and docker system prune is what clears it. Read what it proposes before agreeing, and be careful with -a, which takes every image not attached to a running container — including ones you would rather not pull again over a slow link.

When you are finished with the stand-in, take it down:

cd ~/site && docker compose down
 Container site-web-1 Stopping 
 Container site-web-1 Stopped 
 Container site-web-1 Removing 
 Container site-web-1 Removed 
 Network site_default Removing 
 Network site_default Removed 

The container and its network are gone. The nginx and hello-world images stay behind — docker rmi nginx:1.29-alpine hello-world removes them, or leave them for docker system prune.

Where to run this

A container host is a machine you size by the unit rather than by the plan. Lineserve Cloud Servers are priced that way, with every component published:

Unit KES NGN TZS USD
vCPU core, per month 433 4,330 8,660 4.33
GB of RAM, per month 306 3,060 6,120 3.06
GB of NVMe storage, per month 25 250 500 0.25
IPv4 address, per month 150 1,500 3,000 1.50
IPv6 /64 block, per month 0 0 0 0

Two vCPU, 4 GB and 50 GB with one IPv4 — comfortable for an application container, a database beside it and the proxy in front — works out to KES 3,490, NGN 34,900, TZS 69,800 or USD 34.90 a month at those rates, before tax. When the memory runs short you add a gigabyte rather than moving up a tier and paying for CPU you were not short of.

They run in Nairobi, Lagos and Dar es Salaam. The configurator is on the Cloud Servers page.