My Thoughts

Setting up Docker properly on a fresh server

When I get a virgin server (fresh Ubuntu, nothing installed yet), I do not grab Docker from a random apt install docker.io and hope for the best. I want the official packages, Compose as a plugin, a normal user who can run containers without typing sudo every time, and a layout I can maintain months later.

This is the checklist I use. Examples below assume Ubuntu 22.04 or 24.04. The same idea works on Debian with small path changes.

Before you start: you need SSH access as a user with sudo, a public IP or hostname, and a plan for which ports you will open (usually 22, 80, and 443).

1. Update the system first

Always patch before you install anything important.

sudo apt update
sudo apt upgrade -y
sudo apt install -y ca-certificates curl gnupg lsb-release

2. Remove old Docker leftovers (if any)

On a true virgin server this may do nothing. On a reused box it stops odd conflicts.

sudo apt remove -y docker docker-engine docker.io containerd runc 2>/dev/null || true

3. Add Docker’s official repository

Do not rely on Ubuntu’s default Docker package for production work. Use Docker’s own apt repo.

sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
sudo chmod a+r /etc/apt/keyrings/docker.gpg

echo \
  "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] 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 update

4. Install Engine, CLI, containerd, and Compose

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

Enable and start the service:

sudo systemctl enable --now docker
sudo systemctl status docker --no-pager

You should see active (running).

5. Quick proof that it works

sudo docker run --rm hello-world

If that prints the hello message and exits cleanly, the daemon is fine. Check versions too:

docker --version
docker compose version

Note the space in docker compose. That is the plugin. The old standalone docker-compose binary is not what we install here.

6. Let your user run Docker without sudo

Running everything as root is messy. Add your login user to the docker group. Replace mark with your username.

sudo usermod -aG docker mark
# log out and SSH back in, then:
groups
docker run --rm hello-world
Anyone in the docker group can effectively get root on the host through the Docker socket. Only add people you trust on that machine.

7. Firewall basics (UFW)

Docker can publish ports even when UFW looks closed, so be deliberate about what you expose. Still, start with a sane host firewall:

sudo apt install -y ufw
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable
sudo ufw status

Only open app ports you actually need. Prefer putting public traffic behind a reverse proxy (Traefik or nginx) instead of publishing every container port to the world.

8. A simple folder layout I like

Keep projects under one place so backups and deploys stay predictable.

sudo mkdir -p /opt/apps
sudo chown mark:mark /opt/apps
mkdir -p /opt/apps/demo
cd /opt/apps/demo

Example docker-compose.yml for a tiny web service:

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

Bring it up:

docker compose up -d
docker compose ps
curl -I http://127.0.0.1:8080

Stop and remove when you are done testing:

docker compose down

9. Habits that keep a “proper” setup healthy

  1. Pin images when it matters. Prefer tags like nginx:1.27-alpine over floating latest in production.
  2. Use named volumes for data. Databases and uploads should not live only inside a writable container layer.
  3. Put secrets in env files or a secret store. Do not commit .env with passwords. Keep a .env.example instead.
  4. Log rotation. Default Docker logs can fill a disk. Configure json-file rotation or ship logs elsewhere.
  5. Update on purpose. Patch the host regularly, then update images with a plan and a rollback.

Example daemon log rotation in /etc/docker/daemon.json:

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

Then restart Docker:

sudo systemctl restart docker

10. Optional: auto-start compose stacks on reboot

With restart: unless-stopped (as in the example above), containers come back after a reboot as long as the Docker service starts. That is usually enough.

For more control, keep a small systemd unit that runs docker compose up -d in the project folder after Docker is ready. I only add that when a stack needs an ordered bring-up.

What I avoid on a fresh box

  • Installing Docker only from Ubuntu’s default docker.io package for long-lived servers.
  • Exposing the Docker socket to random containers “for convenience.”
  • Publishing database ports (5432, 3306) to 0.0.0.0 on the public internet.
  • Running production apps as root inside the container when the image supports a non-root user.

Short “done” checklist

  • docker and docker compose work for your user
  • Docker service enabled on boot
  • SSH and only needed web ports allowed
  • Apps live under something like /opt/apps/...
  • Log rotation set, secrets kept out of git

That is enough for a clean start. From there you can add Traefik or nginx-proxy, TLS certificates, and your real application stacks without fighting a messy first install.

Back to My Thoughts Ask me about this