Installing Docker Engine and running your first container

Docker Engine from Docker's apt repository, the docker group, and a first run.

Beginner13 min · lesson 3 of 14

On a freshly installed Linux host, the first docker version usually prints a full Client block and then a single line instead of the Server block: permission denied while trying to connect to the docker API at unix:///var/run/docker.sock. The installation worked. The client simply isn't allowed to talk to the daemon yet, and how you allow it is the first security decision you make with Docker. Here you'll look at what the lab kit installed and how to install the same thing on a real host. Then you'll make that decision explicit before you run the smoke test.

Which Docker to install

On a Linux server or a Linux workstation, install Docker Engine. Docker publishes it as the docker-ce packages in its own apt and dnf repositories, together with containerd.io and the Buildx and Compose plugins. Ubuntu and Debian also ship a docker.io package in their own archives. It works, but it's built and versioned by the distribution and usually trails Docker's releases. It also conflicts with Docker's packages. This track uses Docker's repository so that versions match the release notes.

On macOS and Windows, containers need a Linux kernel, so every option runs Docker Engine inside a Linux VM. Docker Desktop is the official one. It's free for personal use, education and small businesses and needs a paid subscription in larger companies (check the current terms). OrbStack, Colima, Lima, Rancher Desktop and Podman Desktop are alternatives. For this track, use the lab VM from "What Docker is, and why" whichever host you have.

You will also see curl -fsSL https://get.docker.com | sh. That convenience script installs the newest release with no version choice and runs as root on your machine. Docker documents it for test and development machines, not production. If you use it, download it and read it first.

Installing from Docker's apt repository

These steps follow Docker's Ubuntu install guide. They remove conflicting distribution packages, add Docker's signing key and repository, and install the Engine with its plugins. On a host that already runs containers, check first what depends on Ubuntu's containerd and runc (apt-cache rdepends --installed containerd runc), because removing them stops those workloads. Images, containers and volumes under /var/lib/docker aren't removed with the packages.

install on Ubuntu (Docker's guide)
# remove distribution packages that conflict with Docker's (older docker.io, the legacy docker-compose v1, ...)
# no -y: read the list apt prints, including anything it would remove along with them
sudo apt-get remove $(dpkg --get-selections docker.io docker-compose docker-compose-v2 docker-doc docker-buildx podman-docker containerd runc | cut -f1)
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
sudo tee /etc/apt/sources.list.d/docker.sources >/dev/null <<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-get update
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin

The lab kit's setup/install-docker.sh runs the same steps with two additions: it checks the key's fingerprint against the one Docker publishes, and it installs exact versions and holds them. On the lab VM you can see the result:

ubuntu@secopslog-docker:~ · Docker 29.9.2
$ cat /etc/apt/sources.list.d/docker.sources
Types: deb URIs: https://download.docker.com/linux/ubuntu Suites: resolute Components: stable Architectures: arm64 Signed-By: /etc/apt/keyrings/docker.asc
$ apt-cache policy docker-ce | head -n 4
docker-ce: Installed: 5:29.9.2-1~ubuntu.26.04~resolute Candidate: 5:29.9.2-1~ubuntu.26.04~resolute Version table:
$ apt-mark showhold
containerd.io docker-buildx-plugin docker-ce docker-ce-cli docker-ce-rootless-extras docker-compose-plugin
$ systemctl is-active docker.service containerd.service docker.socket
active active active

The repository file names the Ubuntu release (resolute is 26.04), the CPU architecture and the key that must sign the packages. apt-cache policy shows the installed and candidate versions, both 29.9.2. apt-mark showhold lists the six packages that apt upgrade won't move, whether you run it by hand or configuration management does. Holding is a reasonable habit on servers too. Upgrading the Engine restarts the daemon, and with it every container, so you want to choose when that happens. Live restore ("Configuring the daemon safely" in Docker in depth) keeps containers running across a daemon restart. But Docker supports it only for patch upgrades such as 29.9.1 to 29.9.2, not for a move to a new minor release. The three systemd units are the daemon (docker.service), containerd, and docker.socket, which owns /var/run/docker.sock and starts the daemon on demand.

A hold also blocks Docker's security releases, so a held host needs a planned upgrade. Release the hold, install the new versions you chose, check the daemon and your containers, and hold again.

upgrading held Docker packages
# docker-compose-plugin is the current `docker compose` plugin, not the legacy docker-compose v1
pkgs="docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-ce-rootless-extras docker-compose-plugin"
sudo apt-mark unhold $pkgs
sudo apt-get update
apt-cache madison docker-ce | head -n 3 # pick the version string to install
sudo apt-get install docker-ce=<VERSION> docker-ce-cli=<VERSION> containerd.io=<CONTAINERD_VERSION> \
docker-buildx-plugin=<BUILDX_VERSION> docker-ce-rootless-extras=<VERSION> docker-compose-plugin=<COMPOSE_VERSION>
docker version && docker ps # daemon back, containers running
sudo apt-mark hold $pkgs

Client and daemon

ubuntu@secopslog-docker:~ · Docker 29.9.2
$ docker version
Client: Docker Engine - Community Version: 29.9.2 API version: 1.56 Go version: go1.26.10 Git commit: a82e528 Built: Tue Oct 20 18:44:33 2026 OS/Arch: linux/arm64 Context: default Server: Docker Engine - Community Engine: Version: 29.9.2 API version: 1.56 (minimum version 1.40) Go version: go1.26.10 Git commit: 0367cbe Built: Tue Oct 20 18:44:33 2026 OS/Arch: linux/arm64 Experimental: false containerd: Version: v2.4.2 GitCommit: b7a25261e029d68978bbf290df0a62fc819b3c51 runc: Version: 1.5.2 GitCommit: v1.5.2-0-g29dd3dc2 docker-init: Version: 0.19.0 GitCommit: de40ad0

Two blocks mean the client reached the daemon. The Client block is the docker binary you ran. The Server block is dockerd and the components under it. Those are containerd 2.4.2, runc 1.5.2, and docker-init, the tiny init process --init uses. API version 1.56 is what both speak. The daemon still accepts clients down to API 1.40, so older CLIs and SDKs keep working. OS/Arch is linux/arm64 on this VM and linux/amd64 on Intel and AMD machines. If the Server block is replaced by an error saying the client can't connect, the daemon isn't running. Check sudo systemctl status docker and sudo journalctl -u docker.

ubuntu@secopslog-docker:~ · Docker 29.9.2
$ docker info | grep -E 'Server Version|Storage Driver|driver-type|Cgroup|Root Dir|Operating System|Architecture'
Server Version: 29.9.2 Storage Driver: overlayfs driver-type: io.containerd.snapshotter.v1 Cgroup Driver: systemd Cgroup Version: 2 Operating System: Ubuntu 26.04.1 LTS Architecture: aarch64 Docker Root Dir: /var/lib/docker
$ sudo ls /var/lib/docker /var/lib/containerd
/var/lib/containerd: io.containerd.content.v1.content io.containerd.snapshotter.v1.btrfs io.containerd.grpc.v1.introspection io.containerd.snapshotter.v1.erofs io.containerd.metadata.v1.bolt io.containerd.snapshotter.v1.native io.containerd.runtime.v2.task io.containerd.snapshotter.v1.overlayfs io.containerd.sandbox.controller.v1.shim tmpmounts io.containerd.snapshotter.v1.blockfile /var/lib/docker: buildkit engine-id network rootfs swarm volumes containers image plugins runtimes tmp

docker info describes the daemon. The storage lines matter most. overlayfs with driver-type: io.containerd.snapshotter.v1 means Docker uses the containerd image store. In that setup containerd keeps image content and unpacked layers under /var/lib/containerd. Meanwhile /var/lib/docker holds containers, volumes, networks and build cache, with no overlay2 directory. That's the default for fresh installs since Docker Engine 29. A host upgraded from an older Engine keeps the classic overlay2 storage driver and its data under /var/lib/docker until someone migrates it, so you'll meet both on real servers. "Where Docker keeps data" in Docker in depth explains the difference. The cgroup lines say Docker uses cgroup v2 through systemd, which is what the resource-limit lessons assume.

Who may talk to the daemon

Anyone who can open /var/run/docker.sock can use the full Docker API. Look at its permissions, at your groups, and at what a user outside the docker group gets:

ubuntu@secopslog-docker:~ · Docker 29.9.2
$ ls -l /var/run/docker.sock
srw-rw---- 1 root docker 0 Oct 26 12:19 /var/run/docker.sock
$ id -nG
ubuntu adm cdrom sudo dip lxd docker
$ sudo -u nobody docker version
Client: Docker Engine - Community Version: 29.9.2 API version: 1.56 Go version: go1.26.10 Git commit: a82e528 Built: Tue Oct 20 18:44:33 2026 OS/Arch: linux/arm64 Context: default permission denied while trying to connect to the docker API at unix:///var/run/docker.sock

The socket belongs to root and the docker group with mode rw-rw----. ubuntu is in docker (the kit added it), so its commands work. The nobody account isn't. The kernel refused its connect() on the socket file, which is why the client still printed its own version and then stopped. That refusal tells you the socket exists and the user lacks permission on it. It doesn't prove that the daemon is healthy. With socket activation, systemd holds the socket even while dockerd is down.

There are three ways to give someone access, and they aren't equally safe.

Giving a user access to Docker
Access to the Docker API
equals root on this host unless the daemon is rootless
shared server
sudo docker ...
explicit privilege step, logged by sudo
your own machine or lab VM
docker group
convenient; root-equivalent, not logged
users who must not be root
rootless Docker
a daemon per user, running as that user

With sudo docker, each command is an explicit, logged privilege step, which suits shared servers where several administrators work. The docker group removes the sudo and is the usual choice on a personal machine. You join it with sudo usermod -aG docker $USER. It takes effect in new login sessions, so log out and in, or start a shell with newgrp docker. Rootless Docker runs a separate daemon as your own user, so the API is no longer a path to root. It costs some features and extra setup. "Rootless Docker" in Advanced container security covers it.

join the docker group (already done on the lab VM)
sudo usermod -aG docker $USER
newgrp docker # or log out and back in
docker version # the Server block appears
Watch out
Membership of the docker group is equivalent to root on that host. The daemon runs as root and does whatever an API client asks, including starting a container that runs as root with the host's whole filesystem mounted into it. The container below already runs as root by default. Add people to the group only when you would also give them root, and never expose the socket to a container without understanding that the same applies there. Over the network, expose the API only with TLS client certificates, or not at all. Advanced container security demonstrates this safely and covers the mitigations.
ubuntu@secopslog-docker:~ · Docker 29.9.2
$ docker run --rm alpine:3.24 id
uid=0(root) gid=0(root) groups=0(root),0(root),1(bin),2(daemon),3(sys),4(adm),6(disk),10(wheel),11(floppy),20(dialout),26(tape),27(video)

The lab check and the smoke test

The kit includes a checker that compares the VM against the versions the lessons used. The relevant lines:

ubuntu@secopslog-docker:~ · Docker 29.9.2
$ /opt/secopslog-lab/setup/verify-lab.sh | grep -E 'docker|image store|cgroup'
PASS cgroup version v2 (unified hierarchy) PASS docker engine 29.9.2 (API 1.56) PASS docker compose 5.6.1 PASS docker buildx 0.38.1 PASS image store containerd image store (overlayfs io.containerd.snapshotter.v1) PASS cgroup driver systemd/2 WARN docker group ubuntu is in the docker group: root-equivalent on this VM (fine for a disposable lab) PASS registry access registry-1.docker.io reachable

The full run prints one line per check (operating system, memory, disk, required tools, AppArmor, registry access) and ends with a count. A FAIL names what to fix. The docker group line is always a WARN on the main VM, deliberately, for the reason above. Then the classic smoke test:

ubuntu@secopslog-docker:~ · Docker 29.9.2
$ docker run --rm hello-world
Unable to find image 'hello-world:latest' locally latest: Pulling from library/hello-world 58dee6a49ef1: Pulling fs layer 58dee6a49ef1: Download complete 58dee6a49ef1: Pull complete c3bdf82c34d1: Download complete Digest: sha256:11a3eb1597e864c51b3f56733cb2e8a90bf0c284bfc05f9444d81c558bc848aa Status: Downloaded newer image for hello-world:latest 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. (arm64v8) 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/

The output narrates what happened. Docker didn't find hello-world:latest locally (you named no tag, so Docker used latest). It pulled the image from Docker Hub and printed the digest of what it downloaded. The pull lines are shown as Docker prints them when its output isn't a terminal. In your terminal the same lines redraw in place as progress bars. Line 2 of the message names the variant it pulled, (arm64v8) on this VM and (amd64) on an Intel or AMD machine. The daemon then created a container, the program in it printed the text and exited, and --rm removed the container. Check three things: docker version shows both blocks, docker info shows the storage you expect, and hello-world runs. Then the installation is done. Anything that fails after that is about your containers, not the install.

Try this

Run sudo -u nobody docker version again, then plain docker version as ubuntu, and compare the two outputs block by block. Before you look, predict which lines both commands print before the permission denied line. Then use the id -nG and ls -l /var/run/docker.sock output above to explain the difference.

Takeaway

The real install decision is who can open /var/run/docker.sock. nobody got permission denied, and ubuntu gets in only through the docker group, which is root-equivalent. Real hosts go wrong when a whole team is added to that group for convenience, or when the six held packages quietly hold back security releases because nobody planned the upgrade.

Quick check
01A new user on a shared build server runs docker ps and gets permission denied while trying to connect to the docker API at unix:///var/run/docker.sock. Which conclusion is justified?
Incorrect — With socket activation systemd owns the socket, so a permission check on the file says nothing about whether dockerd is up.
Correct — The kernel checked the socket's owner, group and mode and refused the connection; the user is neither root nor in the docker group.
Incorrect — Nothing points at the installation. The same command works for root or for members of the docker group.
Incorrect — The client never reached the daemon, so it never got as far as images.
02You run sudo usermod -aG docker $USER and immediately retry docker ps in the same terminal. It still fails with permission denied. Why?
Incorrect — The daemon does not check group membership; the kernel does, on the socket file, using the calling process's groups.
Incorrect — A reboot works, but only because it starts a new login session. Logging out and in, or newgrp docker, is enough.
Correct — Group membership is read when a session starts, so this shell does not have the new group yet.
Incorrect — usermod -aG updated /etc/group correctly; id $USER would already list docker, while id without an argument still shows the old session groups.
03A colleague without sudo rights asks to be added to the docker group on a shared server "just to run tests". What are you giving them?
Correct — Through the API they can start a root container with the host filesystem mounted and change anything, without sudo and without a sudo log entry.
Incorrect — The Docker API has no per-user separation. Every client with access to the socket can manage every container and start new ones with any options.
Incorrect — There is no read-only mode on the socket. Access to it is access to the whole API.
Incorrect — Rootless Docker runs a separate daemon as the user. The group gives access to the rootful daemon, which runs as root.

Related