Page cover image

Setting up LXD Lab with Ansible

In the world of local infrastructure automation and playbook testing, Vagrant/VirtualBox has long been the go-to combo. But what if you could replace all that overhead with something faster, lighter, and closer to real-world Linux systems? That’s exactly what I set out to do with lxd-lab - that uses Ansible and LXD to spin up containerised Linux environments for testing and development. No bloated VMs. No GUI. Just containers, automation, and security done right.

In a previous post, I shared how I streamlined SSH key management in LXD after migrating from Vagrant. This was a simple solution leveraging cloud-init. Later, I delved into the enhanced security of SSH certificate authentication. Despite its robust security, that process involved numerous manual, repetitive steps – particularly when adding new servers or clients.

This repetition sparked an idea: automate the entire lab setup and its intricate workflow. My tool of choice for this challenge? Ansible. After a couple of weeks of dedicated effort in my downtime, I'm excited to share the results of that automation journey in this article.

What is lxd-lab?

It provides a complete, Ansible-orchestrated lab environment built upon LXC containers. It utilises a dedicated lxd_manage Ansible role to automate the provisioning and configuration of these containers, delivering a highly efficient, secure, and modern workflow.

This solution empowers developers and DevOps teams to:

  • Validate Ansible playbooks within a truly representative environment.

  • Rapidly deploy ephemeral development and testing systems without the resource demands of traditional hypervisors like VirtualBox.

  • Implement SSH certificate-based authentication, enhancing security and streamlining connectivity by eliminating the need for manual host key management.

Upgrading My Workflow: Why LXD + Ansible Outperforms Vagrant

For many developers and DevOps professionals, Vagrant paired with VirtualBox has been the go-to for local development environments. However, the overhead of full virtualisation often translates to sluggish performance, high resource consumption, and a less-than-ideal user experience on contemporary Linux desktops.

Enter LXD and Ansible – a powerful duo that revolutionises local lab management. This pairing offers significant improvements in speed, efficiency, and security:

Feature
Vagrant/VirtualBox
LXD + Ansible

Boot Time

Slow (Spinning up full virtual machines)

Fast (Nearly instantaneous container starts)

Disk/CPU Usage

High (Each VM consumes dedicated resources)

Low (Containers share the host kernel efficiently)

SSH Setup

Often manual host key acceptance (TOFU) or basic key-based auth

Seamlessly integrated CA-signed certificates (Enhanced security, no manual key acceptance)

Native Integration

(VirtualBox is a hypervisor, not native to Linux's core container tech)

(LXD is built directly on Linux container technology, offering native performance)

Automation Flexibility

Limited to VM-specific commands and guest OS config

Full Ansible power, orchestrating everything from host to container configuration

Understanding the lxd-lab Workflow

The lxd-lab environment is orchestrated through a combination of key components designed for efficiency and security:

lxd_manage Ansible Role: Container Orchestration

The custom lxd_manage Ansible role is the engine behind container provisioning. It systematically performs the following actions:

  • Container Creation: Instantiates LXC containers using specified remote images, such as ubuntu:24.04.

  • Initial Configuration: Applies predefined profiles and configures hostnames and network settings.

  • Readiness Check: Waits for cloud-init to fully initialise within the container, ensuring it's ready for subsequent operations.

Secure Connectivity: SSH Certificate Authentication

A cornerstone of this lab's security is its reliance on SSH Certificate Authentication. Instead of traditional host key verification, a dedicated SSH Certificate Authority (CA) is used to sign all host keys.

This method delivers several key benefits:

  • Eliminates Manual Fingerprint Confirmation: You'll never encounter "Trust On First Use" (TOFU) prompts, streamlining initial connections.

  • Centralised Trust Management: Provides a single point of control for issuing, trusting, and revoking SSH host certificates.

  • Enhanced Scalability: Ideal for environments with frequently changing or numerous instances, offering a secure and efficient authentication mechanism for labs, server fleets, and CI/CD pipelines.

Getting Started

We can get up and running in a few steps:

  • Clone the repository

$ git clone https://github.com/tylalin/lxd-lab.git
  • View its directory structure

$ cd lxd-lab
$ tree
.
├── ansible.cfg
├── inventory
│   └── hosts.yml
├── LICENSE
├── lxd-lab.yml
├── README.md
├── roles
│   └── lxd_manage
│       ├── defaults
│       │   └── main.yml
│       ├── meta
│       │   └── main.yml
│       ├── tasks
│       │   └── main.yml
│       └── templates
│           ├── hosts.j2
│           └── user-data.yml.j2
└── templates
    └── hosts.j2

9 directories, 12 files
  • Here is how the Ansible inventory (inventory/hosts.yml) looks like. Of course, all of those variables and IP addresses can be changed to your liking as desired.

---
all:
  vars:
    ssh_ca_dir: ~/ca
    ssh_ca_key_name: homelab_ssh_ca
    ssh_ca_key_comment: Homelab SSH CA
    domain: home.lab
    user: tyla
  children:
    cts:
      children:
        servers:
          vars:
            host_key_name: ssh_host_rsa_key
            host_crtkey: "{{ host_key_name }}-cert.pub"
          hosts:
            server1:
              ansible_host: 10.18.34.10
            server2:
              ansible_host: 10.18.34.11
            server3:
              ansible_host: 10.18.34.12
            server4:
              ansible_host: 10.18.34.13
        clients:
          vars:
            user_key_name: id_rsa
            user_crtkey: "{{ user_key_name }}-cert.pub"
          hosts:
            client1:
              ansible_host: 10.18.34.20
            client2:
              ansible_host: 10.18.34.21
  • The main Ansible playbook (lxd-lab.yml) is shown as below.

---
- name: Manage LXD lab
  hosts: all
  connection: local
  gather_facts: false
  roles:
    - lxd_manage

- name: Configure /etc/hosts for DNS name resolution
  hosts: all
  gather_facts: false
  tasks:
    - name: Wait for SSH
      wait_for_connection:
        timeout: 180

    - name: Render /etc/hosts from template to remote target(s)
      template:
        src: hosts.j2
        dest: /etc/hosts
        mode: 0644

- name: Configure SSH CA
  hosts: localhost
  gather_facts: false
  become: true
  become_user: "{{ user }}"
  tasks:
  - name: Ensure CA key directory exists
    file:
      path: "{{ ssh_ca_dir }}"
      state: directory
      mode: 0700

  - name: Generate SSH RSA key pair for CA if not present
    openssh_keypair:
      path: "{{ ssh_ca_dir }}/{{ ssh_ca_key_name }}"
      comment: "{{ ssh_ca_key_comment }}"

  - name: Create directory structure for all hosts
    file:
      path: "{{ ssh_ca_dir }}/{{ item }}"
      state: directory
      mode: 0755
    loop: "{{ groups['all'] }}"

  - name: Cleanup SSH CA directory upon destroy
    file:
      path: "{{ ssh_ca_dir }}"
      state: absent
    tags: [destroy, never]

- name: Configure server-side for SSH certificate authentication
  hosts: servers
  gather_facts: false
  tasks:
    - name: Get ssh_host_rsa_key.pub from server
      fetch:
        src: "/etc/ssh/{{ host_key_name }}.pub"
        dest: "{{ ssh_ca_dir }}/{{ inventory_hostname }}/"
        flat: yes

    - name: Copy CA public key to server
      copy:
        src: "{{ ssh_ca_dir }}/{{ ssh_ca_key_name }}.pub"
        dest: "/etc/ssh/"
        mode: 0644

    - name: Sign SSH host key with CA private key
      command: |
        ssh-keygen -s {{ ssh_ca_key_name }}
                   -I {{ inventory_hostname }}
                   -V +52w
                   -h
                   -n {{ inventory_hostname }}.{{ domain }}
                   {{ inventory_hostname }}/{{ host_key_name }}.pub
      args:
        chdir: "{{ ssh_ca_dir }}"
        creates: "{{ ssh_ca_dir }}/{{ inventory_hostname }}/{{ host_crtkey }}"
      delegate_to: localhost
      become: true
      become_user: "{{ user }}"

    - name: Copy signed host public key to server
      copy:
        src: "{{ ssh_ca_dir }}/{{ inventory_hostname }}/{{ host_crtkey }}"
        dest: "/etc/ssh/"
        mode: 0644

    - name: Ensure SSH CA settings are in sshd_config
      blockinfile:
        path: /etc/ssh/sshd_config
        block: |
          HostKey /etc/ssh/{{ host_key_name }}
          HostCertificate /etc/ssh/{{ host_crtkey }}
          TrustedUserCAKeys /etc/ssh/{{ ssh_ca_key_name }}.pub
        marker: "# {mark} ANSIBLE MANAGED SSH CA CONFIG"
      notify: Restart SSH

  handlers:
    - name: Restart SSH
      systemd_service:
        name: ssh
        state: restarted
        daemon_reload: true

- name: Configure client-side for SSH certificate authentication
  hosts: clients
  gather_facts: false
  tasks:
    - name: Ensure .ssh directory exists for {{ user }}
      file:
        path: "/home/{{ user }}/.ssh"
        state: directory
        owner: "{{ user }}"
        group: "{{ user }}"
        mode: 0700

    - name: Generate SSH RSA key pair for {{ user }} if not present
      openssh_keypair:
        path: "/home/{{ user }}/.ssh/{{ user_key_name }}"
        owner: "{{ user }}"
        group: "{{ user }}"
        comment: "{{ user }}@{{ inventory_hostname }}"

    - name: Get {{ user_key_name }}.pub from client
      fetch:
        src: "/home/{{ user }}/.ssh/{{ user_key_name }}.pub"
        dest: "{{ ssh_ca_dir }}/{{ inventory_hostname }}/"
        flat: yes

    - name: Sign SSH user key with CA private key
      command: |
        ssh-keygen -s {{ ssh_ca_key_name }}
                   -I {{ inventory_hostname }}
                   -V +52w
                   -n {{ user }}
                   {{ inventory_hostname }}/{{ user_key_name }}.pub
      args:
        chdir: "{{ ssh_ca_dir }}"
        creates: "{{ ssh_ca_dir }}/{{ inventory_hostname }}/{{ user_crtkey }}"
      delegate_to: localhost
      become: true
      become_user: "{{ user }}"

    - name: Copy signed user public key to client
      copy:
        src: "{{ ssh_ca_dir }}/{{ inventory_hostname }}/{{ user_crtkey }}"
        dest: "/home/{{ user }}/.ssh/"
        owner: "{{ user }}"
        group: "{{ user }}"
        mode: 0644

    - name: Ensure CA public key is present in ssh_known_hosts
      lineinfile:
        path: /etc/ssh/ssh_known_hosts
        create: yes
        line: "@cert-authority *.{{ domain }} {{ lookup('file', lookup('env', 'HOME') + '/ca/homelab_ssh_ca.pub') }}"
        state: present

Given the self-documenting nature of Ansible playbooks, I'll skip a line-by-line explanation of each play and task.

Standing Up

Prerequisites

  • Ubuntu 24.04 LTS

  • LXD installed (with snapd) and properly setup

  • Ansible (version - 2.16.3)

  • Git to clone my GitHub repository - lxd-lab

Ansible Magic

Run the following command to run Ansible playbook lxd-lab.yml.

$ ansible-playbook lxd-lab.yml 

PLAY [Manage LXD lab] ************************************************************************************************************************************************************************

TASK [lxd_manage : Create LXD instance(s)] ***************************************************************************************************************************************************
changed: [server1]
changed: [server2]
changed: [client2]
changed: [server3]
changed: [server4]
changed: [client1]

TASK [lxd_manage : Update localhost /etc/hosts file for DNS name resolution] *****************************************************************************************************************
ok: [server1]

PLAY [Configure /etc/hosts for DNS name resolution] ******************************************************************************************************************************************

TASK [Wait for SSH] **************************************************************************************************************************************************************************
ok: [server4]
ok: [server1]
ok: [server3]
ok: [server2]
ok: [client2]
ok: [client1]

TASK [Render /etc/hosts from template to remote target(s)] ***********************************************************************************************************************************
changed: [client1]
changed: [client2]
changed: [server4]
changed: [server2]
changed: [server1]
changed: [server3]

PLAY [Configure SSH CA] **********************************************************************************************************************************************************************

TASK [Ensure CA key directory exists] ********************************************************************************************************************************************************
changed: [localhost]

TASK [Generate SSH RSA key pair for CA if not present] ***************************************************************************************************************************************
changed: [localhost]

TASK [Create directory structure for all hosts] **********************************************************************************************************************************************
changed: [localhost] => (item=server1)
changed: [localhost] => (item=server2)
changed: [localhost] => (item=server3)
changed: [localhost] => (item=server4)
changed: [localhost] => (item=client1)
changed: [localhost] => (item=client2)

PLAY [Configure server-side for SSH certificate authentication] ******************************************************************************************************************************

TASK [Get ssh_host_rsa_key.pub from server] **************************************************************************************************************************************************
changed: [server3]
changed: [server4]
changed: [server1]
changed: [server2]

TASK [Copy CA public key to server] **********************************************************************************************************************************************************
changed: [server2]
changed: [server1]
changed: [server4]
changed: [server3]

TASK [Sign SSH host key with CA private key] *************************************************************************************************************************************************
changed: [server2 -> localhost]
changed: [server1 -> localhost]
changed: [server4 -> localhost]
changed: [server3 -> localhost]

TASK [Copy signed host public key to server] *************************************************************************************************************************************************
changed: [server1]
changed: [server3]
changed: [server2]
changed: [server4]

TASK [Ensure SSH CA settings are in sshd_config] *********************************************************************************************************************************************
changed: [server3]
changed: [server2]
changed: [server4]
changed: [server1]

RUNNING HANDLER [Restart SSH] ****************************************************************************************************************************************************************
changed: [server2]
changed: [server3]
changed: [server1]
changed: [server4]

PLAY [Configure client-side for SSH certificate authentication] ******************************************************************************************************************************

TASK [Ensure .ssh directory exists for tyla] *************************************************************************************************************************************************
ok: [client1]
ok: [client2]

TASK [Generate SSH RSA key pair for tyla if not present] *************************************************************************************************************************************
changed: [client1]
changed: [client2]

TASK [Get id_rsa.pub from client] ************************************************************************************************************************************************************
changed: [client1]
changed: [client2]

TASK [Sign SSH user key with CA private key] *************************************************************************************************************************************************
changed: [client2 -> localhost]
changed: [client1 -> localhost]

TASK [Copy signed user public key to client] *************************************************************************************************************************************************
changed: [client1]
changed: [client2]

TASK [Ensure CA public key is present in ssh_known_hosts] ************************************************************************************************************************************
changed: [client1]
changed: [client2]

PLAY RECAP ***********************************************************************************************************************************************************************************
client1                    : ok=9    changed=7    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0   
client2                    : ok=9    changed=7    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0   
localhost                  : ok=3    changed=3    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0   
server1                    : ok=10   changed=8    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0   
server2                    : ok=9    changed=8    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0   
server3                    : ok=9    changed=8    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0   
server4                    : ok=9    changed=8    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0   

Verification Process

Let's verify if all those LXC containers are actually up and running with lxc list command first.

$ lxc list 
+---------+---------+--------------------+-----------------------------------------------+-----------+-----------+
|  NAME   |  STATE  |        IPV4        |                     IPV6                      |   TYPE    | SNAPSHOTS |
+---------+---------+--------------------+-----------------------------------------------+-----------+-----------+
| client1 | RUNNING | 10.18.34.20 (eth0) | fd42:2751:df65:31e1:216:3eff:fee3:7ec8 (eth0) | CONTAINER | 0         |
+---------+---------+--------------------+-----------------------------------------------+-----------+-----------+
| client2 | RUNNING | 10.18.34.21 (eth0) | fd42:2751:df65:31e1:216:3eff:fe36:ec14 (eth0) | CONTAINER | 0         |
+---------+---------+--------------------+-----------------------------------------------+-----------+-----------+
| server1 | RUNNING | 10.18.34.10 (eth0) | fd42:2751:df65:31e1:216:3eff:fe6d:f4ac (eth0) | CONTAINER | 0         |
+---------+---------+--------------------+-----------------------------------------------+-----------+-----------+
| server2 | RUNNING | 10.18.34.11 (eth0) | fd42:2751:df65:31e1:216:3eff:fec6:ccd3 (eth0) | CONTAINER | 0         |
+---------+---------+--------------------+-----------------------------------------------+-----------+-----------+
| server3 | RUNNING | 10.18.34.12 (eth0) | fd42:2751:df65:31e1:216:3eff:feb2:e242 (eth0) | CONTAINER | 0         |
+---------+---------+--------------------+-----------------------------------------------+-----------+-----------+
| server4 | RUNNING | 10.18.34.13 (eth0) | fd42:2751:df65:31e1:216:3eff:fe86:de3c (eth0) | CONTAINER | 0         |
+---------+---------+--------------------+-----------------------------------------------+-----------+-----------+

To verify the SSH certificate authentication workflow, go through the following process.

# SSH into client1
$ ssh client1
Warning: Permanently added 'client1' (ED25519) to the list of known hosts.
Welcome to Ubuntu 24.04.2 LTS (GNU/Linux 6.8.0-60-generic x86_64)

 * Documentation:  https://help.ubuntu.com
 * Management:     https://landscape.canonical.com
 * Support:        https://ubuntu.com/pro

 System information as of Thu Jun  5 11:22:40 UTC 2025

  System load:           0.89
  Usage of /:            2.6% of 18.96GB
  Memory usage:          0%
  Swap usage:            0%
  Temperature:           34.0 C
  Processes:             22
  Users logged in:       0
  IPv4 address for eth0: 10.18.34.20
  IPv6 address for eth0: fd42:2751:df65:31e1:216:3eff:fee3:7ec8


Expanded Security Maintenance for Applications is not enabled.

0 updates can be applied immediately.

Enable ESM Apps to receive additional future security updates.
See https://ubuntu.com/esm or run: sudo pro status


The list of available updates is more than a week old.
To check for new updates run: sudo apt update

tyla@client1:~$ 

# From client1 or client2, SSH into server1, server2, server3 or server4 as below
tyla@client1:~$ ssh server1.home.lab
Welcome to Ubuntu 24.04.2 LTS (GNU/Linux 6.8.0-60-generic x86_64)

 * Documentation:  https://help.ubuntu.com
 * Management:     https://landscape.canonical.com
 * Support:        https://ubuntu.com/pro

 System information as of Thu Jun  5 11:24:31 UTC 2025

  System load:           0.57
  Usage of /:            2.6% of 18.96GB
  Memory usage:          0%
  Swap usage:            0%
  Temperature:           35.0 C
  Processes:             22
  Users logged in:       0
  IPv4 address for eth0: 10.18.34.10
  IPv6 address for eth0: fd42:2751:df65:31e1:216:3eff:fe6d:f4ac


Expanded Security Maintenance for Applications is not enabled.

0 updates can be applied immediately.

Enable ESM Apps to receive additional future security updates.
See https://ubuntu.com/esm or run: sudo pro status


The list of available updates is more than a week old.
To check for new updates run: sudo apt update

tyla@server1:~$ 

As you can see, there is no TOFU prompt upon the first login attempt and SSH authentication is successful with SSH certificates.

Teardown

After you have done with homelab testing and development works, it's also quite easy to clean up the environment with the following command.

$ ansible-playbook lxd-lab.yml -t destroy

PLAY [Manage LXD lab] ************************************************************************************************************************************************************************

TASK [lxd_manage : Remove LXD instance(s)] ***************************************************************************************************************************************************
changed: [server1]
changed: [client2]
changed: [client1]
changed: [server4]
changed: [server3]
changed: [server2]

PLAY [Configure /etc/hosts for DNS name resolution] ******************************************************************************************************************************************

PLAY [Configure SSH CA] **********************************************************************************************************************************************************************

TASK [Cleanup SSH CA directory upon destroy] *************************************************************************************************************************************************
changed: [localhost]

PLAY [Configure server-side for SSH certificate authentication] ******************************************************************************************************************************

PLAY [Configure client-side for SSH certificate authentication] ******************************************************************************************************************************

PLAY RECAP ***********************************************************************************************************************************************************************************
client1                    : ok=1    changed=1    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0   
client2                    : ok=1    changed=1    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0   
localhost                  : ok=1    changed=1    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0   
server1                    : ok=1    changed=1    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0   
server2                    : ok=1    changed=1    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0   
server3                    : ok=1    changed=1    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0   
server4                    : ok=1    changed=1    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0   

$ lxc list 
+------+-------+------+------+------+-----------+
| NAME | STATE | IPV4 | IPV6 | TYPE | SNAPSHOTS |
+------+-------+------+------+------+-----------+

Elevated Security: Leveraging an SSH Certificate Authority

This is a game-changer for lab security, often overlooked in many home setups. Instead of letting Ansible blindly trust new hosts or forcing you to manually manage host keys, this configuration implements a robust SSH Certificate Authority (CA):

  • Your CA signs each container's SSH host key.

  • The CA's public key is distributed to all clients.

  • /etc/ssh/ssh_known_hosts is configured with @cert-authority, establishing a trusted chain.

The outcome? No more annoying fingerprint prompts, no risk of Man-in-the-Middle (MITM) surprises, and an overall cleaner, more secure SSH experience.

Real-World Use Cases

lxd-lab isn't just for tinkering; it's a powerful tool for practical applications:

  • Develop and test Ansible roles efficiently, without the overhead of full virtual machines.

  • Validate playbooks in a controlled, repeatable lab environment, ensuring consistency.

  • Leverage it as a lightweight Continuous Integration (CI) testing environment for rapid feedback.

  • Replace heavy Vagrant/VirtualBox + VM setups for basic infrastructure simulation and proof-of-concept work.

Final Thoughts

If you're ready to modernise your local development workflow and move beyond the overhead of traditional virtual machines, lxd-lab is your answer.

It's a minimal, automated, and secure environment, built with the same robust tools you likely already use in production. With lxd-lab, you'll discover how effortlessly containers can become your new, more efficient VMs.

Last updated

Was this helpful?