Here are the different ways to install Docker via cloud-init. The goal is to use an official cloud image of a distribution and have it ready to use with Docker from the very first boot.

This works anywhere you can pass user-data to cloud-init (public and private clouds, Proxmox, Incus, VirtualBox, etc.); in my case, I tested the following on IncusOS using the cloud variant of its container images.

Note

This article assumes familiarity with how cloud-init works; if the terms user-data and cloud-config don’t mean anything to you, read the Introduction to cloud-init in the official documentation.

Universal method via the script

The most universal way to install Docker is to use the convenience script.

This method works on all distributions officially supported by Docker Inc. (which currently excludes Rocky Linux and Alma Linux), and comes with the following warning from Docker:

Only recommended for testing and development environments.

Without cloud-config data

If you don’t need to use directives specific to the cloud-config format, you can simply use the following line as user-data:

#include https://get.docker.com

The include format tells cloud-init to download the URL and process its content as if it were directly passed as user-data.

https://get.docker.com returns a shell script; cloud-init detects it via the first line (#!/bin/sh) and executes it directly, exactly as if the script itself had been provided as user-data.

This is the most minimal user-data possible, with the advantage that cloud-init handles downloading the script (no need for curl), but the drawback is that it prevents using cloud-config to install packages, configure users, etc.

There are more complex ways to combine include and cloud-config, but in my case it’s more practical to embed the script directly into cloud-config.

Inside cloud-config

To run Docker’s automated installation script within cloud-config, you can use the following snippet:

#cloud-config

packages:
  - curl

runcmd:
  # Download and run the official Docker installation script
  - curl -fsSL https://get.docker.com -o /tmp/get-docker.sh
  - sh /tmp/get-docker.sh

packages ensures that curl is installed (this was not the case in Debian/Ubuntu LXC images until I requested it), then runcmd downloads and executes it.

Why it’s better to avoid curl -fsSL https://get.docker.com | sh and instead split it into two steps:

  • when curl is slow or the connection is unstable, sh starts executing the script while it is still downloading: if the stream is corrupted or truncated, you end up executing an incomplete script, which can leave the system in an inconsistent state
  • additionally, runcmd does not handle pipe errors well: if curl fails (e.g. network not yet ready at boot), the exit code may be masked by sh, and cloud-init won’t detect the failure

Tested successfully on Ubuntu 24.04 & 26.04, Debian 13, and CentOS/RHEL 9–10 (for Rocky you’ll have to wait until Docker Inc. decides to build the packages).

Method via the official repository

Now moving away from the universal script method for testing/development to the recommended production method.

It requires manual configuration of the official repository, so instructions vary by distribution.

Ubuntu/Debian

The most idiomatic/correct way in my opinion is to use the APT module of cloud-init to add Docker’s official repository.

For Ubuntu:

#cloud-config

apt:
  sources:
    docker:
      source: "deb [signed-by=$KEY_FILE] https://download.docker.com/linux/ubuntu $RELEASE stable"
      keyid: 9DC858229FC7DD38854AE2D88D81803C0EBFCD88

packages:
  - docker-ce
  - docker-ce-cli
  - containerd.io
  - docker-buildx-plugin
  - docker-compose-plugin

Remark

The docker-ce package depends on containerd.io and docker-ce-cli, which in turn recommend docker-buildx-plugin and docker-compose-plugin; in theory, listing only docker-ce in packages should be enough.

However, for reliability the official instructions list them all, so I do the same here.

For Debian it’s exactly the same, just replace ubuntu with debian in the source repository URL (https://download.docker.com/linux/debian).

Since the APT module cannot fetch a GPG key from a URL, this method embeds the Docker repository key fingerprint (keyid: 9DC8...) directly; if the key changes, you’ll need to retrieve the new fingerprint with:

curl -s https://download.docker.com/linux/ubuntu/gpg \
| gpg --with-colons --show-keys \
| awk -F: '/^fpr/ { print $10; exit }'

If curl/wget is available in the image, you can work around this limitation by downloading the key separately:

#cloud-config
bootcmd:
  - cloud-init-per once fetch_docker_gpg curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc

apt:
  sources:
    docker:
      source: "deb [signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu $RELEASE stable"

packages:
  - docker-ce
  - docker-ce-cli
  - containerd.io
  - docker-buildx-plugin
  - docker-compose-plugin

Note

Using bootcmd instead of runcmd is necessary due to the module execution order: runcmd runs near the end, after apt and packages. This would cause an error because the key is missing when adding the repository:

[191744.376988] cloud-init[204]: Reading package lists...
[191744.386138] cloud-init[204]: W: OpenPGP signature verification failed: https://download.docker.com/linux/ubuntu resolute InRelease: The following signatures couldn't be verified because the public key is not available: NO_PUBKEY 7EA0A9C3F273FCD8
[191744.386184] cloud-init[204]: E: The repository 'https://download.docker.com/linux/ubuntu resolute InRelease' is not signed.

bootcmd runs very early during instance startup, allowing the key to be fetched before repository configuration and package installation.

cloud-init-per once ensures the command runs only once instead of at every boot, otherwise bootcmd are run on every boot.

If curl is not available, you can install it before fetching the key:

#cloud-config
bootcmd:
  - cloud-init-per once install_curl apt-get -yq install curl
  - cloud-init-per once fetch_docker_gpg curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc

apt:
  sources:
    docker:
      source: "deb [signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu $RELEASE stable"

packages:
  - docker-ce
  - docker-ce-cli
  - containerd.io
  - docker-buildx-plugin
  - docker-compose-plugin

Rocky/Alma

While waiting for the official Docker repository to work on these distributions (don’t hold your breath though, it’s been in the making for years), for Rocky you should use the RHEL repository according to this doc (which makes sense since it is a 1:1 rebuild of RHEL), and for Alma the CentOS one (as it is based on CentOS Stream).

Example for Rocky

Docker provides the docker-ce.repo file defining four repositories in yum/dnf format:

[docker-ce-stable]
name=Docker CE Stable - $basearch
baseurl=https://download.docker.com/linux/rhel/$releasever/$basearch/stable
enabled=1
gpgcheck=1
gpgkey=https://download.docker.com/linux/rhel/gpg

[docker-ce-stable-source]
name=Docker CE Stable - Sources
baseurl=https://download.docker.com/linux/rhel/$releasever/source/stable
enabled=0
gpgcheck=1
gpgkey=https://download.docker.com/linux/rhel/gpg

[docker-ce-test]
name=Docker CE Test - $basearch
baseurl=https://download.docker.com/linux/rhel/$releasever/$basearch/test
enabled=0
gpgcheck=1
gpgkey=https://download.docker.com/linux/rhel/gpg

[docker-ce-test-source]
name=Docker CE Test - Sources
baseurl=https://download.docker.com/linux/rhel/$releasever/source/test
enabled=0
gpgcheck=1
gpgkey=https://download.docker.com/linux/rhel/gpg

The equivalent of apt.sources for Red Hat–based distributions is yum_repos, but I couldn’t find a way to point it directly to this file.

If the Stable repository is enough (the others are disabled anyway and the testing repo URLs are 404s), here is how to configure it:

#cloud-config

yum_repos:
  docker-ce:
    name: Docker CE Stable - $basearch
    baseurl: https://download.docker.com/linux/rhel/$releasever/$basearch/stable
    enabled: true
    gpgcheck: true
    gpgkey: https://download.docker.com/linux/rhel/gpg

packages:
  - docker-ce
  - docker-ce-cli
  - containerd.io
  - docker-buildx-plugin
  - docker-compose-plugin

runcmd:
  # RHEL and clones do not automatically start services
  - systemctl enable --now docker

Personally I like this approach, but alternatively you can translate the official instructions into runcmd:

#cloud-config

runcmd:
  - dnf install -y dnf-plugins-core
  - dnf config-manager --add-repo https://download.docker.com/linux/rhel/docker-ce.repo
  - dnf install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
  - systemctl enable --now docker

I’m less of a fan of this approach, but it works just as well. As is often the case in computing, there isn’t only one right way to do things.

For Alma Linux, replace rhel with centos in the URL : https://download.docker.com/linux/rhel/docker-ce.repo.

Arch

As a bonus and because it is important to me, here is how to proceed on Arch Linux.

There is no official Docker repository for Arch, which makes sense since one of the motivations behind the Docker repos is to provide up-to-date packages on all distros, which Arch already provides due to its rolling release nature.

So you can simply do:

#cloud-config
packages:
  - docker
  - docker-buildx
  - docker-compose

runcmd:
  - systemctl --now enable docker.socket

It is really that simple, which shouldn’t come as a surprise as Arch has the KISS principle at the heart of its principles.

Bonus: adding the user to the docker group

Not recommended for security reasons, but if needed here’s the trick I personally use to make it work across distro:

runcmd:
  # Add the user with UID 1000 to the docker group
  - [ sh, -c, "usermod -aG docker $(awk -F: '$3==1000{print $1}' /etc/passwd)" ]

This assumes the first non-privileged user has UID 1000, which is common but not guaranteed.

It’s needed because each cloud image uses a different default username (generally the distribution name itself), and default/default_user cannot be used:

#cloud-config

groups:
  - docker: default

This results in:

Unable to add group member 'default' to group 'docker'; user does not exist.

Moreover, creating the group this way instead of letting the docker package create it during installation should be avoided, notably because it would end up with a GID ≥ 1000, whereas system accounts normally have GIDs ≤ 999.

I’m not aware of a simple way to say “add the default user to the docker group” without replacing all the other supplementary groups of the default user. So what you sometimes see:

#cloud-config
users:
- name: ubuntu
  groups: docker

results in overwriting the default secondary groups:

$ id
uid=1000(ubuntu) gid=1001(ubuntu) groups=1001(ubuntu),1000(docker)

Whereas normally you would have:

uid=1000(ubuntu) gid=1001(ubuntu) groups=1001(ubuntu),4(adm),24(cdrom),27(sudo),30(dip),1000(lxd)

In the end, the most simple and portable method is to replace the default username and then add that user to the group via a late runcmd:

user:
  name: cloud

runcmd:
  - usermod -aG docker cloud

Complete example for Ubuntu:

#cloud-config

apt:
  sources:
    docker:
      source: "deb [signed-by=$KEY_FILE] https://download.docker.com/linux/ubuntu $RELEASE stable"
      keyid: 9DC858229FC7DD38854AE2D88D81803C0EBFCD88

packages:
  - docker-ce

user:
  name: cloud

runcmd:
  - usermod -aG docker cloud

Of course, if you only use one or two distributions, you can then hardcode the default username of that distribution directly into the configuration:

#cloud-config

apt:
  sources:
    docker:
      source: "deb [signed-by=$KEY_FILE] https://download.docker.com/linux/ubuntu $RELEASE stable"
      keyid: 9DC858229FC7DD38854AE2D88D81803C0EBFCD88

packages:
  - docker-ce

runcmd:
  - usermod -aG docker ubuntu

With all of this, I hope you won’t run into any issues launching instances with the Docker engine ready to use right away!

Happy hacking 💻