IaC Playground
Real Ansible lab with a control node and target container
Lab Architecture
Both containers on vmbr99 (isolated). Control node has SSH access to target only.
Connecting to lab engine...
Checking if real Linux environments are available
Real Container Lab
Launch an ephemeral LXC container for hands-on practice. Auto-destroys after 60 minutes.
Provisioning a real Linux container on isolated infrastructure
Failed to create lab
Challenges
Get familiar with Ansible on the control node
- Run ansible --version to see the installed version
- Note the config file path and Python version shown in the output
- This confirms Ansible is properly installed and ready to use
ansible --version - Open /etc/ansible/hosts with cat or less to see defined hosts
- Look for the [target] group β this is the managed host for this lab
- Inventory files use INI or YAML format to list hosts and groups
cat /etc/ansible/hosts - Run the ping module against the "target" host group
- A successful ping returns "pong" in green β this tests SSH connectivity, not ICMP
- If it fails, check SSH keys and inventory configuration
ansible target -m ping target | SUCCESS => { "ping": "pong" } - Use the shell module with -m shell and pass a command with -a
- The -a flag passes arguments (the actual command) to the module
- Check the output to confirm you can execute arbitrary commands on the target
ansible target -m shell -a "uname -a" - The setup module collects detailed system information (facts) from remote hosts
- Pipe through head -50 to see the first portion without flooding the terminal
- Facts include OS, IP addresses, memory, CPU, and more β these become variables in playbooks
ansible target -m setup | head -50 Run quick tasks without writing a playbook
- Use the command module to run uptime on the target
- The command module is the default β it runs commands directly without a shell
- Review the output to see how long the target has been running
ansible target -m command -a "uptime" - Use the apk module to install curl on the Alpine target
- The name parameter specifies the package; state=present ensures it is installed
- Ansible will skip the task if curl is already present (idempotent behavior)
ansible target -m apk -a "name=curl state=present" target | CHANGED or target | SUCCESS (if already installed)
- Use the copy module with the content parameter to write text directly to a file
- The dest parameter sets the file path on the target host
- This is useful for quick file creation without having a local source file
ansible target -m copy -a "content='Hello from Ansible' dest=/tmp/hello.txt" - Use the command module to cat the file you just created on the target
- The output appears under stdout in the Ansible response
- This verifies the previous copy task worked correctly
ansible target -m command -a "cat /tmp/hello.txt" - Use the file module with state=absent to delete the file
- The path parameter specifies which file to remove
- Running this again will still succeed β Ansible is idempotent, so deleting an absent file is a no-op
ansible target -m file -a "path=/tmp/hello.txt state=absent" target | CHANGED (first run) or target | SUCCESS (already absent)
Create and execute Ansible playbooks
- Create a file called install.yml using vi, nano, or cat with a heredoc
- Start with --- (YAML document marker), then define a play with name:, hosts: target, and become: yes
- Under tasks:, add two entries using the apk module to install curl and wget with state: present
- YAML uses 2-space indentation β do not use tabs
cat > install.yml << 'EOF'
---
- name: Install packages
hosts: target
become: yes
tasks:
- name: Install curl
apk:
name: curl
state: present
- name: Install wget
apk:
name: wget
state: present
EOF - Execute the playbook with ansible-playbook install.yml
- Watch the output β each task shows ok (no change), changed (action taken), or failed
- The PLAY RECAP at the end summarizes results for each host
ansible-playbook install.yml PLAY RECAP with ok=3 changed=2 (on first run)
- Use an ad-hoc command to check that curl and wget are installed
- The which command shows the full path if the binary exists
- Both should return valid paths confirming the playbook worked
ansible target -m shell -a "which curl && which wget" - Edit install.yml and add a new task using the user module
- Set name: labuser, state: present, and optionally shell: /bin/sh
- The user module handles creating the user, home directory, and group automatically
cat >> install.yml << 'EOF'
- name: Create lab user
user:
name: labuser
state: present
shell: /bin/sh
EOF - Run the playbook with --check to simulate execution without making changes
- Check mode predicts what would change β tasks show changed but nothing is actually modified
- This is essential for verifying playbooks before applying to production
ansible-playbook install.yml --check Use Jinja2 templates and variables in playbooks
- Create a new playbook called template-demo.yml
- Add a vars: section under the play level (same indent as tasks:)
- Define variables like app_name, app_port, and app_env β these become available in tasks and templates
cat > template-demo.yml << 'EOF'
---
- name: Template demo
hosts: target
become: yes
vars:
app_name: myapp
app_port: 8080
app_env: production
tasks:
- name: Show variables
debug:
msg: "App {{ app_name }} on port {{ app_port }}"
EOF - Create a file called app.conf.j2 β the .j2 extension marks it as a Jinja2 template
- Use {{ variable_name }} syntax to insert variables
- You can also use {% if %} blocks and {% for %} loops in templates
cat > app.conf.j2 << 'EOF'
# Application Configuration
# Generated by Ansible - do not edit manually
[app]
name = {{ app_name }}
port = {{ app_port }}
environment = {{ app_env }}
hostname = {{ ansible_hostname }}
os_family = {{ ansible_os_family }}
EOF - Add a task using the template module with src: pointing to your .j2 file
- Set dest: to the target path where the rendered config should land
- Ansible renders the template locally, substituting all variables, then copies the result
cat >> template-demo.yml << 'EOF'
- name: Deploy app config
template:
src: app.conf.j2
dest: /tmp/app.conf
mode: "0644"
EOF - Run the playbook β Ansible automatically gathers facts before executing tasks
- The template already references ansible_hostname and ansible_os_family
- Facts are available as variables in any task or template without declaring them in vars:
ansible-playbook template-demo.yml PLAY RECAP with changed=1 for the template task
- Read the deployed file on the target to confirm variables were substituted
- You should see the actual hostname and OS instead of {{ }} placeholders
- This confirms the template engine processed everything correctly
ansible target -m command -a "cat /tmp/app.conf" A rendered config file with real values replacing all {{ }} placeholders Build reactive, conditional automation
- Create a playbook called handler-demo.yml with a handlers: section at the play level
- Handlers are defined like tasks but only run when triggered by notify:
- Define a handler with a descriptive name like "Restart crond" using the service module
cat > handler-demo.yml << 'EOF'
---
- name: Handler and conditional demo
hosts: target
become: yes
tasks:
- name: Ensure crond is installed
apk:
name: dcron
state: present
- name: Start crond service
service:
name: dcron
state: started
- name: Deploy cron config
copy:
content: "# Managed by Ansible\n*/5 * * * * echo heartbeat\n"
dest: /etc/crontabs/root
mode: "0600"
notify: Restart crond
handlers:
- name: Restart crond
service:
name: dcron
state: restarted
EOF - Run the playbook β the Deploy cron config task includes notify: Restart crond
- On the first run, the copy task will be "changed", so the handler fires
- Run it again β the copy task will be "ok" (no change), so the handler is skipped
ansible-playbook handler-demo.yml First run: RUNNING HANDLER [Restart crond]. Second run: no handler triggered.
- Add a task with a when: clause that checks a fact or variable
- when: uses raw Jinja2 expressions without {{ }} β just write the condition directly
- The task will be skipped with "skipping" status if the condition is false
cat >> handler-demo.yml << 'EOF'
- name: Alpine-only task
debug:
msg: "This host runs Alpine Linux"
when: ansible_os_family == "Alpine"
- name: Skip on Alpine
debug:
msg: "This would run on non-Alpine hosts"
when: ansible_os_family != "Alpine"
EOF - Use register: to capture the result of a task into a variable
- The registered variable contains stdout, stderr, rc (return code), and more
- Add a debug task to inspect the registered variable and see its structure
cat > register-demo.yml << 'EOF'
---
- name: Register demo
hosts: target
tasks:
- name: Check disk usage
command: df -h /
register: disk_result
- name: Show disk output
debug:
var: disk_result.stdout_lines
EOF
ansible-playbook register-demo.yml - Combine register: and when: to make decisions based on command output
- For example, check if a file exists and only create it if missing
- This pattern replaces complex shell scripting with clean, readable Ansible logic
cat > conditional-demo.yml << 'EOF'
---
- name: Conditional with register
hosts: target
tasks:
- name: Check if /tmp/marker exists
stat:
path: /tmp/marker
register: marker_check
- name: Create marker if missing
copy:
content: "Created by Ansible\n"
dest: /tmp/marker
when: not marker_check.stat.exists
- name: Report marker status
debug:
msg: "Marker already existed, skipping creation"
when: marker_check.stat.exists
EOF
ansible-playbook conditional-demo.yml First run: "Create marker" is changed. Second run: "Create marker" is skipped, "Report marker status" fires.
Structure your automation with roles
- Run ansible-galaxy init to scaffold a standard role directory structure
- This creates directories for tasks, handlers, templates, files, vars, defaults, and meta
- Explore the created structure with ls -la roles/webserver/ to see what was generated
ansible-galaxy init --init-path=roles webserver && ls -la roles/webserver/ - Edit roles/webserver/tasks/main.yml to add tasks for the role
- Tasks in a role do not need the play-level boilerplate (hosts:, become:) β they are just a list of tasks
- Each task file starts with --- and a list of task definitions
cat > roles/webserver/tasks/main.yml << 'EOF'
---
- name: Install nginx
apk:
name: nginx
state: present
- name: Create web root
file:
path: "{{ web_root }}"
state: directory
mode: "0755"
- name: Deploy index page
template:
src: index.html.j2
dest: "{{ web_root }}/index.html"
notify: Restart nginx
EOF - Edit roles/webserver/defaults/main.yml to define variables with sensible defaults
- Defaults have the lowest precedence β they can be overridden by vars:, group_vars, or extra-vars
- This makes your role flexible and reusable across different environments
cat > roles/webserver/defaults/main.yml << 'EOF'
---
web_root: /var/www/html
nginx_port: 80
site_title: "Ansible Lab"
EOF - Create site.yml with a roles: section that references your webserver role
- Add a template file at roles/webserver/templates/index.html.j2 for the role to deploy
- Also add a handler in roles/webserver/handlers/main.yml for the restart notification
cat > roles/webserver/templates/index.html.j2 << 'EOF'
<html><body><h1>{{ site_title }}</h1><p>Served from {{ ansible_hostname }}</p></body></html>
EOF
cat > roles/webserver/handlers/main.yml << 'EOF'
---
- name: Restart nginx
service:
name: nginx
state: restarted
EOF
cat > site.yml << 'EOF'
---
- name: Deploy webserver
hosts: target
become: yes
roles:
- webserver
EOF - Execute site.yml β Ansible automatically finds and runs tasks, handlers, and templates from the role
- Verify the index page was deployed by fetching it from the target
- Notice how the playbook is clean and short β all complexity lives in the role
ansible-playbook site.yml && ansible target -m command -a "cat /var/www/html/index.html" The HTML file on the target contains the rendered site_title and hostname from the template.
Coordinate complex deployments across multiple hosts
- Create a playbook with two separate plays, each with its own hosts: directive
- The first play targets localhost (the control node) for preparation tasks
- The second play targets the target group for deployment β plays run sequentially
cat > orchestrate.yml << 'EOF'
---
- name: Prepare on control node
hosts: localhost
connection: local
tasks:
- name: Generate deployment timestamp
command: date +%Y%m%d-%H%M%S
register: deploy_timestamp
- name: Create deployment manifest
copy:
content: "deploy_id: {{ deploy_timestamp.stdout }}\n"
dest: /tmp/deploy-manifest.yml
- name: Deploy to targets
hosts: target
become: yes
tasks:
- name: Show deployment start
debug:
msg: "Starting deployment on {{ inventory_hostname }}"
- name: Ensure app directory exists
file:
path: /opt/myapp
state: directory
mode: "0755"
EOF - Add serial: to a play to limit how many hosts are updated at once
- serial: 1 updates one host at a time, serial: "50%" updates half the group
- This prevents taking down all servers simultaneously during a deployment
cat > rolling.yml << 'EOF'
---
- name: Rolling deployment
hosts: target
become: yes
serial: 1
tasks:
- name: Deploy application update
copy:
content: "version: 2.0\n"
dest: /opt/myapp/version.txt
- name: Verify deployment
command: cat /opt/myapp/version.txt
register: version_check
- name: Confirm version
assert:
that:
- "'2.0' in version_check.stdout"
fail_msg: "Deployment verification failed!"
EOF
ansible-playbook rolling.yml - Use delegate_to: to run a task on a different host than the play targets
- Common use: update a load balancer from a webserver play, or write to a central log
- The task runs on the delegated host but the inventory_hostname variable still refers to the original target
cat > delegate.yml << 'EOF'
---
- name: Delegation demo
hosts: target
tasks:
- name: Record deployment on control node
copy:
content: "{{ inventory_hostname }} deployed at {{ ansible_date_time.iso8601 }}\n"
dest: "/tmp/deploy-log-{{ inventory_hostname }}.txt"
delegate_to: localhost
- name: Run actual task on target
command: echo "Deployed on {{ inventory_hostname }}"
EOF
ansible-playbook delegate.yml - Add run_once: true to a task that should execute on only the first host in the group
- This is useful for database migrations, schema updates, or one-time setup tasks
- The task runs on the first host in the batch but its results are available to all hosts
cat > run-once.yml << 'EOF'
---
- name: Run-once demo
hosts: target
tasks:
- name: One-time setup (runs on first host only)
copy:
content: "initialized: true\nsetup_host: {{ inventory_hostname }}\n"
dest: /tmp/cluster-init.txt
delegate_to: localhost
run_once: true
- name: Per-host task
debug:
msg: "Configuring {{ inventory_hostname }}"
EOF
ansible-playbook run-once.yml - Create a master playbook that imports other playbooks in sequence
- import_playbook is a top-level directive β it goes at the play list level, not inside a play
- This lets you compose complex workflows from smaller, testable playbook files
cat > master.yml << 'EOF'
---
- import_playbook: orchestrate.yml
- import_playbook: rolling.yml
- name: Final verification
hosts: target
tasks:
- name: Confirm everything is deployed
command: ls -la /opt/myapp/
register: final_check
- name: Show results
debug:
var: final_check.stdout_lines
EOF
ansible-playbook master.yml All three playbooks run in sequence: orchestrate, then rolling, then the final verification play.
Manage secrets securely in your automation
- Use ansible-vault create to make a new encrypted file β it will prompt for a vault password
- For this lab, use a simple password like "labpass" since this is a practice environment
- The file is encrypted with AES-256 and safe to commit to version control
echo "labpass" > /tmp/.vault-pass && ansible-vault create --vault-password-file=/tmp/.vault-pass secrets.yml - Use ansible-vault edit to open the encrypted file, or create it with content directly
- Store database passwords, API keys, tokens, and other secrets as YAML variables
- The file looks like normal YAML when decrypted but is AES-256 encrypted on disk
cat > secrets-plain.yml << 'EOF'
---
db_password: "s3cret_P@ssw0rd"
api_key: "ak_live_12345abcde"
app_secret: "super-secret-token-here"
EOF
ansible-vault encrypt --vault-password-file=/tmp/.vault-pass secrets-plain.yml
cat secrets-plain.yml The cat output shows encrypted content starting with $ANSIBLE_VAULT;1.1;AES256, not the original YAML.
- Use vars_files: in your playbook to include the encrypted vault file
- Reference the vault variables like any other variable with {{ variable_name }}
- Ansible decrypts the vault at runtime and makes variables available seamlessly
cat > secure-deploy.yml << 'EOF'
---
- name: Deploy with secrets
hosts: target
become: yes
vars_files:
- secrets-plain.yml
tasks:
- name: Deploy app config with secrets
copy:
content: |
[database]
password = {{ db_password }}
[api]
key = {{ api_key }}
dest: /tmp/app-secrets.conf
mode: "0600"
- name: Verify secret file permissions
file:
path: /tmp/app-secrets.conf
mode: "0600"
EOF - Execute the playbook with --vault-password-file to provide the decryption key
- Alternatively, use --ask-vault-pass to be prompted interactively
- Ansible decrypts vault files in memory only β they are never written to disk in plaintext
ansible-playbook secure-deploy.yml --vault-password-file=/tmp/.vault-pass Playbook runs successfully, deploying the config with decrypted secrets to the target.
- Use ansible-vault encrypt to encrypt a plaintext YAML file in place
- Use ansible-vault view to see the decrypted contents without editing
- Use ansible-vault decrypt to permanently remove encryption (be careful in repos)
cat > extra-vars.yml << 'EOF'
---
backup_key: "backup-encryption-key-2026"
monitoring_token: "mon_tok_abcdef"
EOF
ansible-vault encrypt --vault-password-file=/tmp/.vault-pass extra-vars.yml && echo "--- Encrypted content ---" && head -3 extra-vars.yml && echo "--- Decrypted view ---" && ansible-vault view --vault-password-file=/tmp/.vault-pass extra-vars.yml The encrypted file shows $ANSIBLE_VAULT header, then ansible-vault view shows the original YAML content.
What You Can Do
- Control node with Ansible pre-installed
- Target node accessible via SSH (pre-configured keys)
- Full playbook execution environment
- Jinja2 templates, roles, and handlers
- Ansible Vault for secret management
- Alpine Linux with apk package manager on target
Learning Objectives
- Run ad-hoc commands
- Understand inventory files
- Ping and gather facts
- Write and execute playbooks
- Use variables and templates
- Deploy configuration files
- Create roles
- Use handlers and conditionals
- Register and use variables
- Multi-play orchestration
- Ansible Vault secrets
- Dynamic inventory and delegation
Key Commands
ansible --versionCheck Ansibleansible target -m pingPing targetansible-playbookRun playbookansible-playbook --checkDry runansible target -m setupGather facts--- Select a tool and click "Validate" to see the output
Quick Reference
resourceDefine infrastructure resourcesproviderConfigure cloud/service providersvariableDefine input variablesoutputDefine output valuesmoduleReusable configuration groupsdataQuery existing resourcesIaC Best Practices
π Version Control
Store all infrastructure code in Git. Track changes, review PRs, and maintain history.
π¦ Modularity
Break configurations into reusable modules. DRY (Don't Repeat Yourself) applies to infrastructure too.
π§ͺ Testing
Validate configurations before applying. Use plan/dry-run modes to preview changes.
π Secrets Management
Never commit secrets. Use vault solutions, environment variables, or encrypted backends.