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:
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?