Two bar charts measured 3 November 2026 on Ubuntu 24.04. Log file after 2 minutes of a noisy container: no limit 78 MB, with limit 21 MB. Memory used on a 4 GB server: clean Ubuntu 409 MB, with Docker running 472 MB, with nginx and PostgreSQL in Compose 519 MB.

A fresh Docker install on Ubuntu has two defaults that catch people out. A published port goes straight past ufw, so a firewall that says "deny" still lets the world reach your container. And container logs have no size limit, so a chatty app slowly fills the disk.

This guide installs Docker Engine and Compose from Docker's own repository, then fixes both. We ran every command below in this order on a clean Ubuntu 24.04.5 server (2 vCPU, 4 GB, kernel 6.8) on 3 November 2026, with a second server acting as the outside world. That gave Docker 29.9.2 and Compose v5.7.0. Yours may be newer.

What it costs

MeasureResult
Install time for the Docker packages43 s
Disk used by the Docker packages470 MB
Memory used, clean Ubuntu409 MB
Memory used, Docker running, no containers472 MB (63 MB more)
Memory used, plus nginx and PostgreSQL 18 in Compose519 MB
Container memory per docker statsnginx 3.8 MiB, PostgreSQL 25.7 MiB, both idle
Disk after pulling those images and starting them2,893 MB used in all, 1,236 MB more than a clean server

Memory is MemTotal minus MemAvailable. Install time depends on your connection to Docker's servers, so treat 43 seconds as a rough size. If you're choosing a server, Docker itself is a rounding error: what you run inside it decides, and we measured common stacks here.

Step 1: install from Docker's repository

Ubuntu's own docker.io package isn't the one Docker publishes and maintains, so use Docker's repository. Run these as root, or put sudo in front:

bash
apt-get update
apt-get install -y ca-certificates curl
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
chmod a+r /etc/apt/keyrings/docker.asc

cat > /etc/apt/sources.list.d/docker.sources <<EOF
Types: deb
URIs: https://download.docker.com/linux/ubuntu
Suites: $(. /etc/os-release && echo "$VERSION_CODENAME")
Components: stable
Signed-By: /etc/apt/keyrings/docker.asc
EOF

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

Check that it works:

bash
docker --version
docker compose version
systemctl is-active docker        # active
docker run --rm hello-world       # prints "Hello from Docker!"

To run Docker without sudo you'd add a user to the docker group, but that group is root on the server in all but name: anyone in it can mount / into a container. Keep it to accounts you'd give root.

Step 2: see the ufw gap for yourself

Turn on ufw with only SSH allowed, then publish a container port:

bash
ufw default deny incoming
ufw default allow outgoing
ufw allow 22/tcp
ufw --force enable

docker run -d --name web -p 8080:80 nginx:alpine
docker run -d --name web2 -p 127.0.0.1:8081:80 nginx:alpine
ufw status        # only 22/tcp is listed

From another machine, we got this:

Portufw status sayscurl from outside
8080, published with -p 8080:80Not listed, so denied200, open to the world
8081, published with -p 127.0.0.1:8081:80Not listed, so deniedNo connection
9999, nothing listeningNot listed, so deniedNo connection

The reason is the order of the firewall chains. Docker sends published ports through its own NAT rules, so the traffic is forwarded to the container and never reaches ufw's input rules. You can see the rule that opened it:

bash
iptables -t nat -S DOCKER
# -A DOCKER ! -i docker0 -p tcp -m tcp --dport 8080 -j DNAT --to-destination 172.17.0.2:80

Step 3: close the gap

There are two fixes, and you can use both.

Publish on localhost when a proxy sits in front

If nginx or Caddy on the host handles public traffic, nothing else needs a public port. Write 127.0.0.1:8080:80 instead of 8080:80, and the container is only reachable from the server itself. This is the simplest fix and it covers most setups. Here's the proxy side of that setup.

Make ufw decide for every published port

Docker leaves one chain for you, DOCKER-USER, which it checks before its own rules. Send traffic from your public network interface through ufw's rules there, and drop whatever they don't allow. Find your interface first:

bash
ip route show default        # "dev eth0" is the name you want

Add this to the end of /etc/ufw/after.rules, replacing eth0 with your interface:

text
*filter
:DOCKER-USER - [0:0]
:ufw-user-forward - [0:0]
-A DOCKER-USER -m conntrack --ctstate RELATED,ESTABLISHED -j RETURN
-A DOCKER-USER -i eth0 -j ufw-user-forward
-A DOCKER-USER -i eth0 -j DROP
COMMIT
bash
ufw reload
iptables -S DOCKER-USER     # shows the three rules

Now open a port on purpose with ufw route allow. Use the port inside the container, not the published one, because by the time this chain runs the address has already been translated. For -p 8080:80 that's port 80:

bash
ufw route allow proto tcp from any to any port 80

What we measured from outside at each stage, including after a reboot to prove it lasts:

Stage8080 (published)8081 (localhost only)
Before the fix200No connection
After the DOCKER-USER rulesNo connectionNo connection
After ufw route allow on port 80200No connection
Route rule deleted, server rebootedNo connectionNo connection

This test was over IPv4. If you turn on IPv6 for Docker, repeat it against your IPv6 address. Keep a second SSH session open while you edit firewall rules, and if you haven't locked down the rest of the server yet, start with hardening SSH, ufw and fail2ban.

Step 4: cap the container logs

By default Docker uses the json-file log driver with no limit on size. To see what that means we ran a container writing about 0.65 MB of logs a second for 2 minutes, once with the defaults and once with a limit:

SetupLog files after 120 sAt this rate, per day
Default, no limit78 MB in one fileAbout 56 GB, so a 30 GB disk is full in under 13 hours
max-size 10m, max-file 321 MB in three files (about 2 + 10 + 10 MB)Never more than 30 MB

That rate is deliberately harsh, to get a result in minutes. A more normal 1 KB a second is 86 MB a day, which fills the same disk in about a year, long after you've forgotten about it. Set the limit for the whole server in /etc/docker/daemon.json:

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

This only applies to containers created after the change. We checked: the container that already existed still showed {json-file map[]}, no limit, after restarting Docker. Recreate old ones:

bash
docker inspect -f '{{.HostConfig.LogConfig}}' NAME    # want max-file and max-size in the map
docker compose up -d --force-recreate                 # for a Compose project

A limit also means old logs are gone, past 30 MB per container here. If you need history, ship logs elsewhere or raise the numbers. Restarting Docker also stops running containers unless live-restore is on, so do this before you have traffic.

Step 5: run something with Compose

A small project with a web server on localhost only and a database with no published port at all. Note the volume path: the PostgreSQL 18 images keep data under /var/lib/postgresql, not the /var/lib/postgresql/data you'll see in older examples. With images to download, docker compose up -d --wait took 55 seconds on our server.

yaml
services:
  web:
    image: nginx:alpine
    restart: unless-stopped
    ports:
      - "127.0.0.1:8080:80"
  db:
    image: postgres:18-alpine
    restart: unless-stopped
    environment:
      POSTGRES_PASSWORD: change-me
    volumes:
      - dbdata:/var/lib/postgresql
volumes:
  dbdata:
bash
mkdir -p /srv/hello && cd /srv/hello       # save the file above as compose.yaml
docker compose up -d --wait
docker compose ps
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8080/      # 200
docker compose exec -T db psql -U postgres -tc 'select version()'    # PostgreSQL 18.6
docker stats --no-stream
docker system df                           # disk used by images, containers, volumes

Services in the same Compose project find each other by name, so an app added to this file reaches the database at db:5432 without any port published. From outside, port 8080 gave no connection, as it should.

Which of our servers can run this

ServerDockerNotes from our test
$20 a month and upYes, every stepYour own kernel, so everything above applies
Under $20, Docker picked in the order's installsYeshello-world ran on a 2 vCPU, 4 GB server and ufw works. Over IPv4 only your SSH port is reachable from outside for now, so these fit private jobs better than public sites
Under $20, Docker not pickedNoIt installs, but docker run fails with a runc create failed error

The stack above used about 110 MB on top of a clean server, so memory isn't what limits you on any of these. Our test of swap on small servers covers what happens when a build or a spike goes over.

Sources

Our own run on Ubuntu 24.04.5, kernel 6.8.0-147, QEMU/KVM, 3 November 2026; limits checked on a real small server the same day. Install Docker Engine on Ubuntu, Docker with ufw, JSON File logging driver, ufw manual.

Want this done for you? Pick Docker in the installs when you order a server and it comes with Compose ready to go.

See the servers