Running a NGINX container using rootless Docker with Ansible

Thomas Sjögren
6 min readFeb 9, 2021

--

An Ansible example to install a rootless Docker server and start a NGINX container.

The Security Layers

Rootless mode allows running the Docker daemon and containers as a non-root user to mitigate potential vulnerabilities in the daemon and the container runtime. (Docker)

In addition to creating a dedicated NGINX container user running a rootless Docker daemon, we’ll also reduce the number of kernel capabilities available to the container.

The user running the daemon can of course run multiple containers, or we can create multiple users running multiple instances of the Docker daemon.

In this example we’ll name the user nginxu, since it will only run the NGINX container.

The Variables: rootfull, user name and Docker release

Since the point with this article is to get a rootless Docker server and a basic web container up and running, we’ll be ignoring most of the configuration options and only focusing on the most relevant parts: the user that will run the service and how to get the Docker binaries if you don’t want to install a rootful daemon.

In ./defaults/main.yml we first choose if we want to install Docker using .deb packages, also known as the "rootful" installation since it requires root permissions and installs the upstream Docker daemon or download the static binaries and do a manual install.

If we set docker_rootful: false we will download the static binaries and do a manual install, not requiring any root permissions.

We then define the name of the Docker user that will be created with the docker_user variable. This user will download and install the binaries if docker_rootful: false or else the user will be the one running the container.

The docker_url and docker_release variables defines where we find the relevant binaries and which version we should use when doing a manual installation.

docker_release_shasum and docker_release_rootless_shasum are used to verify the files when downloaded using the get_url module.

docker_rootful: false
docker_user: nginxu
docker_url: "https://download.docker.com/linux/static/stable/x86_64"
docker_release: "20.10.2"
docker_release_shasum: "97017e32a8ecbdd1826bb3c7b1424303ee0dea3f900d33591b1df5e394ed4eed"
docker_release_rootless_shasum: "4ebdad1d4743ccca68d183fccdb978aa8b62e7b24743fff36099bd29e46380e0"

The Tasks: user creation, Docker installation and container management

All tasks, templates and related code are available in https://github.com/konstruktoid/ansible-docker-rootless and can be tested and tried using Ansible Molecule with the molecule-vagrant plugin.

User creation and systemd lingering

Using the user module we create the {{ docker_user }}, and also the docker_user_registered variable. docker_user_registered will be used as a easy way to get the {{ docker_user }} environment settings.

It’s probably not in any way optimal using registered variables in this way, using set_fact might be more suitable in a more production like environment.

- name: add user "{{ docker_user }}"
become: 'yes'
user:
name: "{{ docker_user }}"
state: present
shell: /bin/bash
register: docker_user_registered

Since we created a non-system user we need to ensure that any user processes aren’t killed due to a missing login session.

Enabling lingering means that user@.service is started automatically during boot, even if the user is not logged in, and that the service is not terminated when the user logs out. Enabling lingering allows the user to run processes without being logged in. (systemd-run)

- name: ensure "{{ docker_user }}" is lingering
stat:
path: "/var/lib/systemd/linger/{{ docker_user }}"
register: docker_user_lingering
- name: enable lingering for "{{ docker_user }}"
become: 'yes'
command: "loginctl enable-linger {{ docker_user }}"
when: not docker_user_lingering.stat.exists

User directories

Since we are installing the Docker daemon in a directory owned by {{ docker_user }}, in this example the user home directory, we also need to create the required Ansible, Docker and systemd directories.

Note that we use become_user: "{{ docker_user }}" and the {{ docker_user_registered.home }} variable to create the directories with the correct ownership and locations.

- name: create "{{ docker_user }}" ansible tmp dir
become: 'yes'
become_user: "{{ docker_user }}"
file:
path: "{{ docker_user_registered.home }}/.ansible/tmp"
state: directory
mode: "0700"
- name: create "{{ docker_user }}" docker bin dir
become: 'yes'
become_user: "{{ docker_user }}"
file:
path: "{{ docker_user_registered.home }}/bin"
state: directory
mode: "0700"
- name: create "{{ docker_user }}" systemd config dir
become: 'yes'
become_user: "{{ docker_user }}"
file:
path: "{{ docker_user_registered.home }}/.config/systemd/user"
state: directory
mode: "0700"

Manual Docker installation

After creating the necessary directories we, download the packages defined by url: "{{ docker_url }}/docker-{{ docker_release }}.tgz" and url: "{{ docker_url }}/docker-rootless-extras-{{ docker_release }}.tgz" and verify them using the get_url module.

- name: download docker-{{ docker_release }}.tgz
become: 'yes'
become_user: "{{ docker_user }}"
get_url:
url: "{{ docker_url }}/docker-{{ docker_release }}.tgz"
dest: "{{ docker_user_registered.home }}/docker-{{ docker_release }}.tgz"
checksum: "sha256:{{ docker_release_shasum }}"
- name: download docker-rootless-extras-{{ docker_release }}.tgz
become: 'yes'
become_user: "{{ docker_user }}"
get_url:
url: "{{ docker_url }}/docker-rootless-extras-{{ docker_release }}.tgz"
dest: "{{ docker_user_registered.home }}/docker-rootless-extras-{{ docker_release }}.tgz"
checksum: "sha256:{{ docker_release_rootless_shasum }}"

Extracting the downloaded packages are done by the unarchive module, with the {{ docker_user_registered.home }}/bin directory as a destination.

- name: extract docker-{{ docker_release }}.tgz into {{ docker_user_registered.home }}/bin
become: 'yes'
become_user: "{{ docker_user }}"
unarchive:
src: "{{ docker_user_registered.home }}/docker-{{ docker_release }}.tgz"
dest: "{{ docker_user_registered.home }}/bin"
extra_opts:
- --strip-components=1
remote_src: 'yes'
- name: extract docker-rootless-extras-{{ docker_release }}.tgz into {{ docker_user_registered.home }}/bin
become: 'yes'
become_user: "{{ docker_user }}"
unarchive:
src: "{{ docker_user_registered.home }}/docker-rootless-extras-{{ docker_release }}.tgz"
dest: "{{ docker_user_registered.home }}/bin"
extra_opts:
- --strip-components=1
remote_src: 'yes'

The rootless Docker systemd service file requires some modifications before the daemon can be managed. We use the previously created docker_user_registered variable to set the correct environment values and ExecStart setting in the docker.service template before rendering the template in the {{ docker_user_registered.home }}/.config/systemd/user/ directory.

Environment="DOCKER_HOST=unix:///run/user/{{ docker_user_registered.uid }}/docker.sock"
Environment="PATH={{ docker_user_registered.home }}/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
Environment="XDG_RUNTIME_DIR=/run/user/{{ docker_user_registered.uid }}"
ExecStart="{{ docker_user_registered.home }}/bin/dockerd-rootless.sh"
- name: add docker systemd service
become: 'yes'
become_user: "{{ docker_user }}"
template:
src: docker.service.j2
dest: "{{ docker_user_registered.home }}/.config/systemd/user/docker.service"
backup: 'yes'
mode: 0600

The docker.service is enabled and started using the Ansible systemd module. In addition to using become: 'yes' and become_user: "{{ docker_user }}", we need to set scope: user.

- name: enable and start rootless docker
become: 'yes'
become_user: "{{ docker_user }}"
systemd:
name: docker.service
enabled: 'yes'
state: started
scope: user
daemon_reload: 'yes'

Package Docker installation

If we’re installing using apt, we'll only need to install the docker-ce and docker-ce-rootless-extras packages.

- name: install docker
become: 'yes'
apt:
name: ['docker-ce', 'docker-ce-rootless-extras']
state: present

After installation, we’ll disable the rootful Docker daemon since our priority is reducing any attack surface and the usage of root privileges.

- name: disable rootful docker daemon
become: 'yes'
systemd:
name: docker
state: stopped
enabled: 'no'
tags:
- docker

We then install the rootless Docker daemon as the {{ docker_user }} unless /run/user/{{ docker_user_registered.uid }}/docker.sock exists.

- name: stat {{ docker_user_registered.uid }}/docker.sock
become: 'yes'
become_user: "{{ docker_user }}"
stat:
path: "/run/user/{{ docker_user_registered.uid }}/docker.sock"
register: docker_rootless_sock
- name: install rootless docker
become: 'yes'
become_user: "{{ docker_user }}"
command: dockerd-rootless-setuptool.sh install
when: not docker_rootless_sock.stat.exists

After the installation we enable and start the daemon.

- name: enable and start rootless docker
become: 'yes'
become_user: "{{ docker_user }}"
systemd:
name: docker.service
enabled: 'yes'
state: started
scope: user
daemon_reload: 'yes'

Container management

Running containers is not that much different from when a rootful Docker daemon is used, but we still need to become the unprivileged user and adapt any paths to the user working directory.

The tasks required to create the below mentioned directories are available in the repository but not shown in this document.

- name: add nginx default website config
become: 'yes'
become_user: "{{ docker_user }}"
template:
src: default.conf.j2
dest: "{{ docker_user_registered.home }}/nginx/conf.d/default.conf"
mode: 0644

The container is managed by the docker_container module. Using read-only volumes and only allow a subset of kernel capabilities, we further reduce any potential attack surface.

- name: nginx container
become: 'yes'
become_user: "{{ docker_user }}"
docker_container:
name: nginx
image: konstruktoid/nginx
state: started
restart: 'yes'
ports:
- "8080:80"
cap_drop: all
capabilities:
- chown
- dac_override
- net_bind_service
- setgid
- setuid
volumes:
- "{{ docker_user_registered.home }}/nginx/:/etc/nginx/:ro"
- "{{ docker_user_registered.home }}/nginx/www/{{ ansible_hostname }}:/var/www/{{ ansible_hostname }}:ro"
- "{{ docker_user_registered.home }}/nginx/log:/var/log/nginx"
pull: 'yes'
restart_policy: on-failure
restart_retries: 3
hostname: "{{ ansible_nodename }}"
container_default_behavior: compatibility

The Result: Running molecule

If Ansible Molecule with the molecule-vagrant plugin and related software is installed, running molecule test --destroy=never should allow you to visit http://127.0.0.1:8080 after all tasks and the full test sequence has finished.

--

--

Thomas Sjögren
Thomas Sjögren

Written by Thomas Sjögren

Various tech ramblings but usually GNU/Linux system administration with focus on high availability and security.

No responses yet