Setting up new Debian desktop with rootless container support
This post is about setting up my “new” desktop, an old HP 600 G4 I’ve had since 2023 but not got around to setting up, as a replacement for a 1st generation P4 desktop that my brother-in-law gave me but draws more power than everything else on my desk combined. This system will inherit the name Galaxy. I already have a fully automated process for (re)installing systems, although it needed some tweaks to work for dynamic IP addressed hosts. It was developed for my servers, and although I want to make them more dynamic the current version makes some assumptions about the host being in DNS (which my dynamic hosts are not, currently). Fortunately a lot of the groundwork for this was already done, just not backported to the (re)installation playbooks.
Fixing reinstall process for dynamic hosts
The Desktop does not have a static IP, so I had to make some changes to the (re)installation process that has previously only been used for hosts with static IP assignments and DNS entries. Thanks to other work to dynamically connect to hosts there were actually only two changes needed to support this, but I’ll explain the full reinstall process to show why only two little changes were needed…
The reinstall process uses three playbooks that are imported in sequence:
-
reinstall.yamlThis playbook destroys the existing partition table (on all disks with partitions), reboots the system and removes existing keys from the local (as in the host Ansible is running on) user’s SSH
known_hostsfile (as these will be invalid when the install has generated new ones). It then imports theinstall.yamlplaybook as it’s last item.Removing the partitions forces the host to fall-through to the next boot method (as disk boot will fail) with the system’s disks in a state that will be seen as uninitialised, and hence available, by the OS installer.
The only change to this playbook was to add the existing host discovery playbook (which also gets the SUDO password for the
ansibleuser) that is already used by most other playbooks for finding the address of dynamic hosts with no DNS entry:# Get connection information for dynamic hosts - name: Host connection information is available import_playbook: discover-hosts.yamlThe reinstall playbook uses the existing credentials to login to the host and prepare it for reinstallation, which are valid (as the old OS is still in place) so this all worked fine as-is.
-
install.yamlThis playbook configures the
auto-install.ipxeconfiguration file for iPXE and sets up a host-specific symlink using the host’s MAC address, which it checks is specified as a variable (e.g. via the inventory) with an assert at the start, to ensure the host boots from it.Next, it configures the DHCP server to temporarily ignore the client ID when handing out leases to the host’s MAC address (so the IP doesn’t keep changing between the EFI PXE/iPXE/installer/installed system) and clears out all but the latest existing lease for the host’s MAC. It then waits until SSH is available on the host’s current IP (indicating the install has completed).
Once SSH is up, it gets the host’s new SSH keys and puts them into the local (as in the host Ansible is being run on) user’s SSH
known_hostsbefore removing the host-specific iPXE configuration symlink. It then imports the bootstrap playbook (bootstrap.yaml, see below) before, finally, removing the DHCP configuration to ignore client ID and the dynamic IP from the known_hosts (which may change after the DHCP configuration is restored).The install playbook doesn’t actually login to the host being installed at all (
bootstrap.yamldoes), it logs into the DHCP and PXE servers, so no changes were needed at all. -
bootstrap.yamlThis is the one that does the initial post-install setup on the newly installed host. This one does need to login to the host, so it also needs to include the host discovery playbook. However, the discover playbook uses host facts (specifically,
user_idto determine the username Ansible is logging in as) but at this stage of the (re)installation process it cannot gather facts - Python has not yet been installed and even if it had, the default login settings will probably use a username that has not yet been created (which happens later in the bootstrap playbook). It uses the fact as the most reliable way to determine the user, as there are so many ways (within and outside of Ansible’s configuration, e.g. via inventory variables, environment variables, SSH client configuration files etc.) the remote user might be set locally.To work around this, I added a new variable to the
discover-hosts.yamlplaybook (DISCOVER_SUDO_HOSTS) to allow overriding the set of hosts to lookup SUDO passwords for in that play:- name: sudo password is set # This requires fact gathering, and it's not guaranteed that we can connect # in some playbook workflows so allow separate skipping of this step with a # different hosts variable. hosts: "{{ DISCOVER_SUDO_HOSTS | default(DISCOVER_HOSTS) | default('all') }}:!dummy"Then, I just needed to set that variable to “not all” hosts (i.e. no hosts) when the discover playbook is imported in
bootstrap.yaml:- name: Host connection information is available import_playbook: discover-hosts.yaml vars: # Ansible user won't be setup to discover facts yet, which is required # for sudo password lookup. DISCOVER_SUDO_HOSTS: '!all'
Installing the new host
My previous host was setup mostly manually.
I created host_vars/galaxy.yaml, moved existing settings (which consisted of just the interfaces that had the one entry with the MAC address of the network interface), added some desktop profiles (copied from my old laptop’s variables), SSH hostkey and temporarily hardcoded the DHCP IP for backup purposes:
interfaces:
- mac_address: aa:bb:cc:dd:ee:ff # Real MAC redacted
# Copied and pasted from defiant
desktop_profiles:
- administration_tools
- audio-visual
- base
- development
- office
- remote-desktop-client
- virtualisation
- linux_desktop_environment_awesome
backuppc_host_settings:
# Hardcoded IP until some sort of DNS arrangement sorted out
ClientNameAlias: "'192.168.20.158'"
My desktop profile was originally developed to be run directly as my “normal” user, on my Dell XPS 13 laptop in 2022.. As a result it, naïvely, used ansible_facts['env'] to lookup up various environment variables (like HOME and XDG_CONFIG_HOME). The problem is that this reports the environment variable’s value at the time the facts were gathered - if (as is the case now) the facts were gathered as a different user to the one we are setting up a desktop for, then the values will be incorrect - in the case of these examples, the HOME and XDG_CONFIG_HOME of the ansible user. To get around this, I added a command to find the XDG_CONFIG_HOME of each desktop user being configured and populate a fact that holds a dictionary mapping user to their directory in my desktop role’s tasks/Linux_base.yaml task file:
- name: XDG config directory is known
become: yes
become_user: '{{ desktop_user }}'
ansible.builtin.shell: '[ -z "$XDG_CONFIG_HOME" ] && echo $HOME/.config || echo $XDG_CONFIG_HOME'
register: xdg_config_dir_output
loop: '{{ desktop_users | default([]) }}'
loop_control:
loop_var: desktop_user
changed_when: false # Read operation
check_mode: no # Run even when in check mode
- name: Dictionary of XDG config directories is initialised
ansible.builtin.set_fact:
desktop_xdg_config_directories: {}
- name: Dictionary of XDG config directories is populated
ansible.builtin.set_fact:
desktop_xdg_config_directories: >-
{{
desktop_xdg_config_directories
|
combine({
desktop_user: xdg_config_dir_output.results | selectattr('desktop_user', 'eq', desktop_user) | map(attribute='stdout') | first
})
}}
loop: '{{ desktop_users | default([]) }}'
loop_control:
loop_var: desktop_user
The remaining changes were all typos, a few URLs that had changed for source packages/repositories and using this new fact. The most complete example is pushing out my awesome window manager configuration files in tasks/linux_desktop_environment_awesome.yaml:
- name: "my awesome configuration files are deployed"
become: true
become_user: '{{ desktop_user }}'
ansible.builtin.copy:
dest: "{{ desktop_xdg_config_directories[desktop_user] }}/awesome/"
src: awesome/config/
force: yes # Overwrite existing files with server copies
tags: ['awesome-rc']
loop: '{{ desktop_users | default([]) }}'
loop_control:
loop_var: desktop_user
For replacing the lookup of HOME, I did not have anywhere where ~ did not work as an alternative - for example (from tasks/linux_lightweight_desktop_utils.yaml):
- name: Set urxvt configuration options
become: true
become_user: '{{ desktop_user }}'
ansible.builtin.copy:
dest: "~/.Xresources"
content: |
URxvt*font: xft:Terminus (TTF):size=10
! See salt-home/states/linux/desktop/utils/urxvt for transparancy options
URxvt*background: black
URxvt*foreground: white
URxvt*fading: 40
URxvt*faceColor: black
URxvt*scrollstyle: plain
URxvt*visualBell: True
URxvt.saveLines: 2000
URxvt.loginShell: True
mode: 0o400
loop: '{{ desktop_users | default([]) }}'
loop_control:
loop_var: desktop_user
I am aware there’s an inconsistency between the capitalisation of linux and Linux (e.g. linux_desktop_environment_awesome.yaml and Linux_base.yaml) - the original task files were all lowercase, then I started using (e.g.) ansible.builtin.include_tasks: '{{ ansible_facts.system }}_base.yaml' to dynamically include Linux_base.yaml and Win32NT_base.yaml on Linux and Windows systems respectively and ansible_facts.system has the first letter capitalised.
Containers
I have a number of Docker containers, which replaced manually building and installing software outside the system’s package manager, particularly for software that updated frequently. My intention was to migrate all of this to Podman, however I encountered some problems passing through DVD drives (I think it’s related to Podman’s more robust security model) so I ended up with a mixed Podman/Docker setup.
Building containers requires a large /var partition, so I added it to the list of logical_volumes overrides in group_vars/desktops.yaml. As the comment notes, I think I should restructure this variable - as the /var override is specific to containers (and so really should be in a specific group for machines expecting to be used to build containers) but the desktops group already has an override to ensure a large /home filesystem:
filesystems_lvm_volume_groups:
- name: vg_{{ inventory_hostname }}
logical_volumes:
# [...]
# XXX this is specifically for a large /var/tmp for podman builds - should be applied to that group only?
- name: var
size: 30G
In order to manage Docker containers, I added community.docker collection to the requirements.yaml (containers.podman was already present):
collections:
# [...]
- community.docker
# [...]
Docker role
My existing Podman role is published on GitHub but my new Docker role involves adding repositories, which creates a dependency on one of my other roles for managing Debian repositories that I have not yet published - so for now, it is not published somewhere publicly.
It is, however, very straight-forward.
Argument specs and defaults
It’s only argument is the mirror to use, as my air-gapped lab requires using a local mirror so this needs to be configurable. So, the meta/argument_specs.yaml is very simple:
---
argument_specs:
main:
short_description: Install and setup Docker
author: Laurence Alexander Hurst
options:
docker_mirror_debian_url_base:
description: Url to use as the base for Docker Debian repositories
type: str
default: https://download.docker.com/linux/debian
...
The defaults/main.yaml is trivial:
---
docker_mirror_debian_url_base: https://download.docker.com/linux/debian
...
Dependency - apt-source
Configuring the meta/main.yaml to include my existing Debian repository role (recently updated to use the newer deb822 format), which will configure the repository including fetching and de-armouring the GPG key for validation, worked like this:
---
dependencies:
- role: apt-source
vars:
name: docker
uri: "{{ docker_mirror_debian_url_base }}"
gpg_key:
url: "{{ docker_mirror_debian_url_base }}/gpg"
suite: '{{ ansible_facts.distribution_release }}'
components:
- stable
src:
no_src: yes
when: ansible_facts['os_family'] == 'Debian'
...
Task file
The tasks/main.yaml file is very straight-forward, just installing Docker. Unlike Podman, I have not configured it properly for rootless use (as I’m using it for one rootful container that I am having difficulty getting Podman to run, as root or not). This is very lazy of me, really, since it should be straight-forward to port setting up the subuid/subgid from my Podman role:
---
# Currently only sets up rootful Docker - see podman role for setting up subuid/subgids for rootless access
- name: Handlers are flushed (so any new repos are synced)
meta: flush_handlers
- name: Docker is installed
become: true
ansible.builtin.package:
name:
- docker-ce
- docker-ce-cli
- containerd.io
- name: containers-storage is installed on Debian
become: true
ansible.builtin.package:
name: containers-storage
when: ansible_facts.distribution == 'Debian'
...
Container building tasks
I created Podman and Docker versions of a tasks file for building images, called build-image.yaml, that pulls the base image if it is missing (unless told not to) and builds the image if the image is missing, tha base image is pulled or the base image is newer than the existing image. These were originally placed in a role that used the generated containers, but I extracted and generalised them so I could place them in the respective roles. The Podman version I added to my existing role on GitHub, the Docker version was added to the new role I just created.
You’ll see Podman tasks file has a copyright and licence header missing from my Docker version, I will add that when I publish it - it is released under the same licence (GPLv3). I also, but have not reproduced here, updated the README.md in my Podman role to document the new entry point and variables.
Argument specs and defaults
The meta/argument_specs.yaml and defaults/main.yaml files were updated for each with the arguments for these entry points. These are almost identical to give me a nice consistent interface to use regardless of which I need to use, despite (as you will see) the tasks being different:
Docker
Extra arguments that needed adding to the defaults file:
docker_image_build_user: root
docker_image_fetch_base: true
docker_image_build_args: []
and the additional section for this new entry-point to argument_specs.yaml:
build-image:
short_description: Build images for use by docker
author: Laurence Alexander Hurst
options:
docker_image_build_user:
description: User to build container as
type: str
default: root
docker_image_base_image:
description: Name of the base image for building from
type: str
required: true
docker_image_fetch_base:
description: Fetch the base image if it does not exist (set to false if base image should have been previously built locally)
type: bool
default: true
docker_image_name:
description: Name of the image to build
type: str
required: true
docker_image_container_file:
description: Dockerfile for building the image
type: str
required: true
docker_image_build_args:
description: List of build args to pass to the build command with `--build-arg`
type: list
elements: dict
options:
name:
description: The argument name
type: str
required: true
value:
description: The argument value
type: str
required: true
default: []
Podman
Extra arguments that needed adding to the defaults file:
podman_image_fetch_base: true
podman_image_build_args: []
and the additional section for this new entry-point to argument_specs.yaml:
build-image:
short_description: Build images for use by Podman
author: Laurence Alexander Hurst
options:
podman_image_build_user:
description: User to build container as
type: str
required: true
podman_image_base_image:
description: Name of the base image for building from
type: str
required: true
podman_image_fetch_base:
description: Fetch the base image if it does not exist (set to false if base image should have been previously built locally)
type: bool
default: true
podman_image_name:
description: Name of the image to build
type: str
required: true
podman_image_container_file:
description: Container for building the image
type: str
required: true
podman_image_build_args:
description: List of build args to pass to the build command with `--build-arg`
type: list
elements: dict
options:
name:
description: The argument name
type: str
required: true
value:
description: The argument value
type: str
required: true
default: []
build-image.yaml tasks files
These were different, as the community.docker collection and containers.podman collection have very different interfaces - particularly when it comes to building images.
However, they work the same way:
- Check if the base image exists
- If the base image does not exit, fetch it (unless
<docker|podman>_image_fetch_basevariable is set to false) -
(re)build the image if any of these are true:
- It is missing
- The base image was fetched
- The base image is newer than the existing image
Docker
The Docker version of tasks/build-image.yaml, to implement the above logic using my interface (variables):
---
- name: Info is gathered on base image {{ docker_image_base_image }}
become: true
become_user: '{{ docker_image_build_user }}'
community.docker.docker_image_info:
name: '{{ docker_image_base_image }}'
register: docker_image_base_info
- name: Base image {{ docker_image_base_image }} is fetched if missing
become: true
become_user: '{{ docker_image_build_user }}'
community.docker.docker_image_pull:
name: '{{ docker_image_base_image }}'
pull: always
when: >-
docker_image_fetch_base
and
docker_image_base_info.images | length == 0
register: docker_image_base_image_updated
- name: Info is gathered on base image {{ docker_image_base_image }} again (in case it was fetched)
become: true
become_user: '{{ docker_image_build_user }}'
community.docker.docker_image_info:
name: '{{ docker_image_base_image }}'
register: docker_image_base_info
- name: Base image {{ docker_image_base_image }} exists
ansible.builtin.assert:
that: docker_image_base_info.images | length > 0
fail_msg: Base image must exist to check if we need to build a new one!
- name: Info is gathered on image {{ docker_image_name }}
become: true
become_user: '{{ docker_image_build_user }}'
community.docker.docker_image_info:
name: '{{ docker_image_name }}'
register: docker_image_info
- name: Image {{ docker_image_name }} is correct
block:
- name: Context folder exists
become: true
become_user: '{{ docker_image_build_user }}'
ansible.builtin.tempfile:
state: directory
register: build_context_path
- name: Dockerfile exists
become: true
become_user: '{{ docker_image_build_user }}'
ansible.builtin.copy:
dest: '{{ build_context_path.path }}/Dockerfile'
content: '{{ docker_image_container_file }}'
mode: '0600'
- name: New {{ docker_image_name }} image is built
become: true
become_user: '{{ docker_image_build_user }}'
community.docker.docker_image_build:
name: '{{ docker_image_name }}'
path: '{{ build_context_path.path }}'
rebuild: always
nocache: true
args: "{{ dict( docker_image_build_args | map(attribute='name') | zip( docker_image_build_args | map(attribute='value') ) ) }}"
register: docker_image_new_image_built
always:
- name: Build context is absent
become: true
become_user: '{{ docker_image_build_user }}'
ansible.builtin.file:
path: '{{ build_context_path.path }}'
state: absent
when: build_context_path is defined
# In some edge cases (e.g. changing the base image), the base might be
# changed but older then the previously built custom image.
when: >-
docker_image_base_image_updated.changed | default(False)
or
docker_image_info.images | length == 0
or
base_created_short | ansible.builtin.to_datetime(iso8601format) > image_created_short | ansible.builtin.to_datetime(iso8601format)
vars:
# Taken from the example at:
# https://docs.ansible.com/ansible/latest/collections/ansible/builtin/to_datetime_filter.html
iso8601format: '%Y-%m-%dT%H:%M:%S.%fZ'
# shorten to microseconds, make sure there's a fractional
# component to match format string.
base_created_short: >-
{{
docker_image_base_info.images[0].Created
| regex_replace("([^.]+)(\.\d{6})(\d*)(.+)", "\1\2\4")
| regex_replace("(:[0-9]+)Z$", "\1.0Z")
}}
image_created_short: >-
{{
docker_image_info.images[0].Created
| regex_replace("([^.]+)(\.\d{6})(\d*)(.+)", "\1\2\4")
| regex_replace("(:[0-9]+)Z$", "\1.0Z")
}}
...
Podman
The Podman version of tasks/build-image.yaml, to implement the above logic using my interface (variables):
---
# Copyright 2025 Laurence Alexander Hurst
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
- name: Info is gathered on base image {{ podman_image_base_image }}
become: true
become_user: '{{ podman_image_build_user }}'
containers.podman.podman_image_info:
name: '{{ podman_image_base_image }}'
register: podman_image_base_info
- name: Base image {{ podman_image_base_image }} is fetched if missing
become: true
become_user: '{{ podman_image_build_user }}'
containers.podman.podman_image:
name: '{{ podman_image_base_image }}'
force: true
when: >-
podman_image_fetch_base
and
podman_image_base_info.images | length == 0
register: podman_image_base_image_updated
- name: Info is gathered on base image {{ podman_image_base_image }} again (in case it was fetched)
become: true
become_user: '{{ podman_image_build_user }}'
containers.podman.podman_image_info:
name: '{{ podman_image_base_image }}'
register: podman_image_base_info
- name: Base image {{ podman_image_base_image }} exists
ansible.builtin.assert:
that: podman_image_base_info.images | length > 0
fail_msg: Base image must exist to check if we need to build a new one!
- name: Info is gathered on image {{ podman_image_name }}
become: true
become_user: '{{ podman_image_build_user }}'
containers.podman.podman_image_info:
name: '{{ podman_image_name }}'
register: podman_image_info
- name: Image {{ podman_image_name }} is correct
block:
- name: Context folder exists
become: true
become_user: '{{ podman_image_build_user }}'
ansible.builtin.tempfile:
state: directory
register: build_context_path
- name: New {{ podman_image_name }} image is built
become: true
become_user: '{{ podman_image_build_user }}'
containers.podman.podman_image:
name: '{{ podman_image_name }}'
path: '{{ build_context_path.path }}'
state: build
force: true
build:
cache: false
force_rm: true
container_file: '{{ podman_image_container_file }}'
extra_args: "{% for item in podman_image_build_args %}\
--build-arg \
{{ item.name | ansible.builtin.quote }}\
=\
{{ item.value | ansible.builtin.quote }}\
{% if not loop.last %} {% endif %}\
{% endfor %}"
register: podman_image_new_image_built
always:
- name: Build context is absent
become: true
become_user: '{{ podman_image_build_user }}'
ansible.builtin.file:
path: '{{ build_context_path.path }}'
state: absent
when: build_context_path is defined
# In some edge cases (e.g. changing the base image), the base might be
# changed but older then the previously built custom image.
when: >-
podman_image_base_image_updated.changed | default(False)
or
podman_image_info.images | length == 0
or
base_created_short | ansible.builtin.to_datetime(iso8601format) > image_created_short | ansible.builtin.to_datetime(iso8601format)
vars:
# Taken from the example at:
# https://docs.ansible.com/ansible/latest/collections/ansible/builtin/to_datetime_filter.html
iso8601format: '%Y-%m-%dT%H:%M:%S.%fZ'
# shorten to microseconds, make sure there's a fractional
# component to match format string.
base_created_short: >-
{{
podman_image_base_info.images[0].Created
| regex_replace("([^.]+)(\.\d{6})(\d*)(.+)", "\1\2\4")
| regex_replace("(:[0-9]+)Z$", "\1.0Z")
}}
image_created_short: >-
{{
podman_image_info.images[0].Created
| regex_replace("([^.]+)(\.\d{6})(\d*)(.+)", "\1\2\4")
| regex_replace("(:[0-9]+)Z$", "\1.0Z")
}}
...