A container is a process on your machine, isolated from the rest of it and shipped with everything it needs to run. Not a virtual machine — there is no second kernel and no emulated hardware, which is why a container starts in milliseconds where a VM takes a minute.

This gets you from nothing to running an application, with the two security surprises that most introductions leave out.

1. Install it

Use Docker’s own repository rather than your distribution’s package. The distribution version is often well behind, and on Ubuntu the docker.io package and the snap have caused enough confusion to be worth avoiding.

# Debian, Ubuntu — official convenience script
curl -fsSL https://get.docker.com -o get-docker.sh
less get-docker.sh          # read it before running it
sudo sh get-docker.sh

# Fedora
sudo dnf install docker-ce docker-ce-cli containerd.io docker-compose-plugin

sudo systemctl enable --now docker
docker --version

That less step is the habit recommended in curl and wget — the script is piped straight to a root shell in most instructions you will find, and reading it first costs nothing.

Running docker without sudo

sudo usermod -aG docker $USER
# log out and back in for it to take effect

Understand what you just did. Membership of the docker group is equivalent to root on the machine. Not similar to — equivalent. Anyone in that group can start a container that mounts the host’s entire filesystem and edit anything on it, without ever typing a password or appearing in the sudo logs.

That is a reasonable trade on your own laptop. On a shared server it means the docker group is your list of administrators, and should be treated that way. If that is not acceptable, rootless Docker and Podman both exist for exactly this reason.

2. The mental model

ThingIs
ImageA read-only template. Like a class, or an ISO.
ContainerA running (or stopped) instance of an image. Like an object.
VolumeStorage that outlives the container.
RegistryWhere images are stored — Docker Hub by default.
DockerfileThe recipe for building an image.

The single most important consequence: a container’s filesystem is disposable. Remove the container and everything written inside it is gone. That is a feature, not a bug — but it is why the volumes section below matters more than it looks.

3. Run something

docker run hello-world                    # prove it works
docker run -d -p 8080:80 --name web nginx # a web server on port 8080

Visit http://your-server:8080 and Nginx answers. Nothing was installed on the host.

FlagMeans
-dDetached — run in the background
-p 8080:80Host port 8080 → container port 80
--name webA name, so you are not typing container IDs
-v host:containerMount storage in
-e KEY=valueSet an environment variable
--rmDelete the container when it exits
-itInteractive with a terminal — for shells
--restart unless-stoppedCome back after a reboot

The port mapping reads host first. -p 8080:80 means “expose the container’s 80 as 8080 on this machine”. Getting it backwards is a rite of passage.

4. Look at what is running

docker ps                        # running containers
docker ps -a                     # including stopped ones
docker logs web                  # its output
docker logs -f web               # follow it live
docker exec -it web bash         # a shell inside it
docker stop web                  # stop
docker start web                 # start again
docker rm web                    # delete the container
docker images                    # images on this machine
docker stats                     # live resource usage

docker logs and docker exec -it ... bash are the two you will use constantly. The first is where an application’s errors go — containers log to stdout by convention rather than to files. The second drops you inside the container to look around, which is how you find out that the config file is not where you thought.

If bash is not found, the image is probably Alpine-based: use sh.

5. Keep the data

Two ways to make storage survive the container:

# Named volume — Docker manages it, best for databases
docker run -d --name db \
  -e POSTGRES_PASSWORD_FILE=/run/secrets/pw \
  -v pgdata:/var/lib/postgresql/data \
  postgres:16

# Bind mount — a real directory on the host, best for config and code
docker run -d --name web -p 8080:80 \
  -v /srv/site:/usr/share/nginx/html:ro \
  nginx

Named volumes live under /var/lib/docker/volumes and are the right default for application data. Bind mounts point at a path you chose, which makes them right for things you want to edit and back up normally — and :ro makes it read-only, which is worth adding whenever the container has no business writing.

docker volume ls
docker volume inspect pgdata
docker run --rm -v pgdata:/data -v $(pwd):/backup alpine \
  tar czf /backup/pgdata.tar.gz -C /data .

That last command is the standard trick for backing up a named volume: run a throwaway container with both the volume and a host directory mounted, and tar from one to the other. For a database, take a proper dump instead — the reasoning is in the backups guide.

6. Compose, for anything with more than one part

Once you have a web server, a database and a couple of environment variables, remembering the docker run line stops being realistic. compose.yaml:

services:
  web:
    image: nginx
    ports:
      - "127.0.0.1:8080:80"
    volumes:
      - ./site:/usr/share/nginx/html:ro
    restart: unless-stopped
    depends_on:
      - db

  db:
    image: postgres:16
    environment:
      POSTGRES_PASSWORD: ${DB_PASSWORD}
    volumes:
      - pgdata:/var/lib/postgresql/data
    restart: unless-stopped

volumes:
  pgdata:
docker compose up -d          # start everything
docker compose ps             # what is running
docker compose logs -f web    # follow one service
docker compose down           # stop and remove containers (volumes survive)
docker compose down -v        # ...and delete the volumes too. Careful.
docker compose pull && docker compose up -d    # update images

Note two things in that file. ${DB_PASSWORD} comes from a .env file that is not in version control. And the port is written 127.0.0.1:8080:80 rather than 8080:80 — which brings us to the thing worth knowing most.

.dockerignore deserves a moment here too, because keeping .env out of version control does not keep it out of an image. If that file was ever committed — even in a commit whose message says the secrets were removed — it is still in the repository’s history, and a COPY . /app in a Dockerfile ships .git into the image along with everything else. Add both .env and .git to .dockerignore. What ships in your container image has the reproduction, and what to do if it already shipped.

Docker and your firewall

This surprises people badly, sometimes publicly.

Publishing a port with -p writes iptables rules that bypass ufw. You can have ufw status showing a tidy deny-by-default policy, run -p 5432:5432 on a database container, and have that database reachable from the internet. ufw will still report the port as blocked, because as far as ufw is concerned it is.

The fix is to bind published ports to loopback unless you genuinely want them public:

-p 127.0.0.1:8080:80        # only this machine can reach it
-p 8080:80                  # the whole internet can, firewall or not

Then put Nginx in front as a reverse proxy and let it be the only thing listening publicly. Check what is actually exposed from another machine, not from the server:

sudo ss -tulpn | grep docker      # on the server
nc -zv your-server-ip 5432        # from your laptop — should refuse

Networking basics covers reading that output.

Docker eats disk

Old images, stopped containers, dangling build layers and unused volumes accumulate quietly. On a build server this is the most common cause of a full disk.

docker system df                 # what is using space
docker image prune               # dangling images — safe
docker container prune           # stopped containers
docker system prune              # both, plus build cache
docker system prune -a           # ALSO every image not backing a running container
docker volume prune              # unused volumes — this deletes DATA

docker system prune -a and volume prune are the two to read carefully before confirming. The first will re-download images you still wanted; the second deletes application data that no running container happens to be using at that moment. See disk space.

Container logs also grow without limit by default. Cap them in /etc/docker/daemon.json:

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

Then sudo systemctl restart docker. This applies to containers created afterwards, not existing ones.

Other things that catch people

  • :latest is not a version — and nor is postgres:16.2. Both are tags, and tags move: official images get rebuilt and pushed over the same version tag whenever their base picks up a fix. Use a version tag anyway, because it stops an unattended pull handing you a major upgrade you did not plan. But the thing that actually pins an image is a digest — postgres@sha256:…, which you can read off docker buildx imagetools inspect postgres:16.2. What ships in your container image covers the three things a digest still does not promise.
  • Processes inside containers run as root by default, and with a bind mount that root can write to your host filesystem. Use --user, or an image that drops privileges.
  • Changes inside a running container are lost when it is recreated. If you fixed something with docker exec, put it in the Dockerfile or compose file too, or you will fix it again next week.
  • docker compose down -v deletes volumes. Without -v your data survives. It is one character between a restart and a data loss.
  • Images are not audited. Anyone can publish to Docker Hub. Prefer official images and verified publishers, and be as sceptical of a random image as you would be of a random curl | sh.

And when you come to build an image of your own rather than run somebody else’s, that is building a container image — the long one that starts where this page’s glossary entry for “Dockerfile” stops. It is worth reading before your first build rather than after, because the failures it covers all arrive on a build that reported success.

Quick reference

docker run -d --name x -p 127.0.0.1:8080:80 image
docker ps -a                  docker logs -f x
docker exec -it x bash        docker stop x / start x / rm x
docker images                 docker pull image:tag

docker compose up -d          docker compose ps
docker compose logs -f svc    docker compose down     (-v deletes volumes)
docker compose pull && docker compose up -d

docker system df              docker system prune
docker volume ls              docker volume inspect name
ss -tulpn | grep docker       # what did Docker actually expose

Related