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 is not allowed to talk to the daemon yet, and how you allow it is the first security decision you make with Docker. This lesson looks at what the lab kit installed and how to install the same thing on a real host, then makes that decision explicit before running 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 is built and versioned by the distribution and usually trails Docker's releases, and it 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 is 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 are not 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.8.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.8.2-1~ubuntu.26.04~resolute Candidate: 5:29.8.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.8.2. apt-mark showhold lists the six packages that apt upgrade, run by hand or by configuration management, will not move. 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.8.1 to 29.8.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.8.2
$ docker version
Client: Docker Engine - Community Version: 29.8.2 API version: 1.56 Go version: go1.26.8 Git commit: 7fc2dff Built: Wed Sep 30 19:32:08 2026 OS/Arch: linux/arm64 Context: default Server: Docker Engine - Community Engine: Version: 29.8.2 API version: 1.56 (minimum version 1.40) Go version: go1.26.8 Git commit: 8af9fe3 Built: Wed Sep 30 19:32:08 2026 OS/Arch: linux/arm64 Experimental: false containerd: Version: v2.3.6 GitCommit: ee2735368117d2eb259779949d5e75cdafec9761 runc: Version: 1.5.1 GitCommit: v1.5.1-0-g8f2685a4 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: containerd 2.3.6, runc 1.5.1, 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 cannot connect, the daemon is not running: check sudo systemctl status docker and sudo journalctl -u docker.

ubuntu@secopslog-docker:~ · Docker 29.8.2
$ docker info | grep -E 'Server Version|Storage Driver|driver-type|Cgroup|Root Dir|Operating System|Architecture'
Server Version: 29.8.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: containerd keeps image content and unpacked layers under /var/lib/containerd, and /var/lib/docker holds containers, volumes, networks and build cache, with no overlay2 directory. That is 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 will 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.8.2
$ ls -l /var/run/docker.sock
srw-rw---- 1 root docker 0 Oct 7 23:29 /var/run/docker.sock
$ id -nG
ubuntu adm cdrom sudo dip lxd docker
$ sudo -u nobody docker version
Client: Docker Engine - Community Version: 29.8.2 API version: 1.56 Go version: go1.26.8 Git commit: 7fc2dff Built: Wed Sep 30 19:32:08 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 is not, and 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 does not 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 are not 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 and it takes effect in new login sessions (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, at the cost of 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.8.2
$ docker run --rm alpine:3.22 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.8.2
$ /opt/secopslog-lab/setup/verify-lab.sh | grep -E 'docker|image store|cgroup'
PASS cgroup version v2 (unified hierarchy) PASS docker engine 29.8.2 (API 1.56) PASS docker compose 5.6.0 PASS docker buildx 0.37.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.8.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:5e23090353324d887c48ad5e5c56d294eab81588df9605b07d1afe895f9cc8f8 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 did not find hello-world:latest locally (you named no tag, so Docker used latest), pulled it from Docker Hub, and printed the digest of what it downloaded. The pull lines are shown as Docker prints them when its output is not 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. When docker version shows both blocks, docker info shows the storage you expect and hello-world runs, the installation is done; anything that fails after that is about your containers, not the install.

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.

Try this

Work through “The lab check and the smoke test” yourself on a sandbox you can throw away, following the commands above in order. Then break one step deliberately and re-run, so you have seen the failure before it finds you.

Takeaway

If you keep one thing from installing docker engine and running your first container, keep “The lab check and the smoke test”. Decide now which check you will run when this shows up on a live system, and write it somewhere your team will find it.

Related