// Configuration management without the complexity.
ANSIBLE CHANGED HOW WE THINK ABOUT SERVERS.
Imagine managing thousands of servers with a simple, human-readable languageβno agents to install, no complex infrastructure to maintain. Ansible connects to your servers over SSH, executes tasks, and ensures your systems are configured exactly as specified.
WHY ANSIBLE?
Ansible uses an agentless architecture. You install Ansible on one machine (the control node), and it manages all your managed nodes via SSH. No daemon, no agent, no additional portsβjust SSH and Python. It's simple, powerful, and declarative.
BECOME AN AUTOMATION ENGINEER.
Learn to write Ansible playbooks, create reusable roles, manage complex infrastructure, and integrate automation into your CI/CD pipelines. Whether you're managing a handful of servers or thousands, Ansible scales with you.
12 lessons. Complete Ansible control.
What is Ansible? Installing and configuring your first Ansible environment.
BeginnerManaging inventory, groups, variables, and running ad-hoc commands.
BeginnerYAML syntax, tasks, plays, and your first playbook.
BeginnerCommon modules: file, copy, command, shell, service, and more.
IntermediateVariables, facts, registered variables, and magic variables.
IntermediateWhen conditions, loops with_loop, and template iteration.
IntermediateCreating reusable roles, role structure, and Ansible Galaxy.
IntermediateJinja2 templating, variables in templates, and conditionals.
IntermediateEncrypting sensitive data, Ansible Vault, and security best practices.
AdvancedError handling, blocks, rescue, always, and debugging.
AdvancedTesting Ansible, molecule, ansible-lint, and CI/CD integration.
AdvancedAnsible Tower/AWX, collections, and production architectures.
AdvancedAnsible is an open-source automation tool developed by Red Hat. It handles configuration management, application deployment, task automation, and IT orchestration. Unlike other automation tools, Ansible is designed to be simple, readable, and agentless.
The key principles of Ansible:
Ansible follows a simple architecture:
Ansible connects to managed nodes over SSH (or WinRM for Windows), executes modules, and then exits. No daemon runs on the managed nodes.
# macOS
brew install ansible
# Ubuntu/Debian
sudo apt update
sudo apt install ansible
# CentOS/RHEL
sudo yum install epel-release
sudo yum install ansible
# pip (works on any platform with Python)
pip install ansible
# Verify installation
ansible --version
The inventory is a file that lists your managed nodes. It can be static (a file) or dynamic (from a cloud provider):
# inventory
[webservers]
web1.example.com
web2.example.com
web3.example.com
[databases]
db1.example.com
db2.example.com
[production:children]
webservers
databases
Modules are the units of work Ansible executes. There are thousands of built-in modules for almost any task:
file - Manage files and directoriescopy - Copy files to managed nodescommand - Execute commandsservice - Manage servicesyum/apt - Package managementgit - Git operationstemplate - Template files with variablesPlaybooks are YAML files that describe the desired state of your systems:
# site.yml
---
- name: Configure web servers
hosts: webservers
tasks:
- name: Install nginx
apt:
name: nginx
state: present
- name: Start nginx service
service:
name: nginx
state: started
Let's verify Ansible is working by connecting to localhost:
# Create a simple inventory
echo "localhost" > inventory
# Test connectivity (ping module)
ansible -i inventory -m ping all
# Run a simple command
ansible -i inventory -m command -a "uptime" all
The inventory is the foundation of Ansible. It defines what hosts you manage and how to connect to them.
# Basic inventory
web1.example.com
web2.example.com
[webservers]
web1.example.com
web2.example.com
[databases]
db1.example.com
db2.example.com
[production:children]
webservers
databases
all:
hosts:
web1.example.com:
web2.example.com:
children:
webservers:
hosts:
web1.example.com:
web2.example.com:
databases:
hosts:
db1.example.com:
db2.example.com:
You can define variables directly in the inventory:
[webservers]
web1.example.com ansible_host=192.168.1.10 ansible_user=ubuntu
web2.example.com ansible_host=192.168.1.11 ansible_user=ubuntu
[webservers:vars]
ansible_python_interpreter=/usr/bin/python3
ansible_ssh_private_key_file=~/.ssh/id_rsa
For cloud environments, use dynamic inventory scripts:
# AWS EC2 dynamic inventory
# Download and configure ec2.py
curl -o ec2.py https://raw.githubusercontent.com/ansible/ansible/devel/contrib/inventory/ec2.py
chmod +x ec2.py
# Use it
ansible -i ec2.py -m ping all
# Or configure in ansible.cfg
# [inventory]
# enable_plugins = aws_ec2, host_list, yaml, ini
Ad-hoc commands are quick one-liners for tasks that don't need a playbook:
# Check all hosts are reachable
ansible all -i inventory -m ping
# Gather facts
ansible all -i inventory -m setup
# Check disk space
ansible all -i inventory -m command -a "df -h"
# Install a package (Debian/Ubuntu)
ansible all -i inventory -m apt -a "name=vim state=present"
# Install a package (RHEL/CentOS)
ansible all -i inventory -m yum -a "name=vim state=present"
# Copy a file
ansible all -i inventory -m copy -a "src=./file.txt dest=/tmp/file.txt"
# Create a directory
ansible all -i inventory -m file -a "path=/tmp/testdir state=directory"
# Manage a service
ansible all -i inventory -m service -a "name=nginx state=started enabled=yes"
# Restart a service on all servers
ansible production -i inventory -m service -a "name=nginx state=restarted"
Ansible patterns let you target specific hosts:
# All hosts
ansible all
# Single host
ansible web1.example.com
# Group
ansible webservers
# Multiple groups (union)
ansible 'webservers:databases'
# Exclude
ansible '!databases'
# Intersection
ansible 'production:&webservers'
The ansible.cfg file configures Ansible behavior:
[defaults]
inventory = inventory
host_key_checking = False
retry_files_enabled = False
gathering = smart
fact_caching = jsonfile
fact_caching_connection = /tmp/ansible_facts
fact_caching_timeout = 3600
[privilege_escalation]
become = True
become_method = sudo
become_user = root
become_ask_pass = False
[ssh_connection]
ssh_args = -o ControlMaster=auto -o ControlPersist=60s
pipelining = True
Playbooks are the heart of Ansible. They describe the desired state of your systems in a readable YAML format.
Ansible playbooks are written in YAML. Key rules:
# Simple string
name: hello
# List
fruits:
- apple
- banana
- cherry
# Dictionary
person:
name: John
age: 30
# Boolean (all these are equivalent)
enabled: true
enabled: yes
enabled: on
# Multi-line strings
description: |
This is a multi-line
string that preserves
newlines.
# playbook.yml
---
- name: My first playbook
hosts: webservers
become: yes
tasks:
- name: Install nginx
apt:
name: nginx
state: present
when: ansible_os_family == "Debian"
- name: Start nginx service
service:
name: nginx
state: started
enabled: yes
Run it:
ansible-playbook -i inventory playbook.yml
Each play has several keys:
- name: Play description # Optional, shown in output
hosts: webservers # Required - target hosts
become: yes # Run as sudo
become_user: root # Become this user
gather_facts: yes # Gather system information
vars: # Variables for this play
nginx_port: 80
vars_files: # Variable files
- secrets.yml
roles: # Roles to apply
- common
- nginx
tasks: # Tasks to execute
- name: Task description
module_name:
module_arg: value
tasks:
- name: Install nginx
apt:
name: nginx
state: present
update_cache: yes
register: apt_result # Store result in variable
changed_when: false # Override changed status
notify: restart nginx # Trigger handler
- name: Ensure nginx is running
service:
name: nginx
state: started
failed_when: false # Don't fail if this errors
Handlers are tasks that only run when notified:
tasks:
- name: Copy nginx config
copy:
src: nginx.conf
dest: /etc/nginx/nginx.conf
notify: restart nginx
- name: Enable nginx service
service:
name: nginx
enabled: yes
handlers:
- name: restart nginx
service:
name: nginx
state: restarted
Ansible modules are idempotentβrunning them multiple times produces the same result:
# This is idempotent - running again won't change anything
- apt:
name: nginx
state: present
# This removes nginx - running again keeps it absent
- apt:
name: nginx
state: absent
Modules are the building blocks of Ansible. There are thousands of modules for different tasks. Let's explore the most commonly used ones.
# Create a directory
- file:
path: /tmp/mydir
state: directory
mode: '0755'
owner: root
group: root
# Create a symlink
- file:
src: /tmp/file
dest: /tmp/link
state: link
# Create an empty file
- file:
path: /tmp/file
state: touch
# Remove a file/directory
- file:
path: /tmp/mydir
state: absent
# Copy a file
- copy:
src: files/nginx.conf
dest: /etc/nginx/nginx.conf
owner: root
group: root
mode: '0644'
validate: nginx -t %s # Validate before copying
# Copy with content
- copy:
dest: /tmp/hello.txt
content: "Hello, World!\n"
# Install a package
- apt:
name: nginx
state: present
update_cache: yes
# Install multiple packages
- apt:
name:
- nginx
- vim
- curl
state: present
# Remove a package
- apt:
name: nginx
state: absent
# Upgrade all packages
- apt:
upgrade: yes
# Install with yum
- yum:
name: nginx
state: present
enablerepo: epel
# Install with dnf (RHEL 8+)
- dnf:
name: nginx
state: present
# Start and enable a service
- service:
name: nginx
state: started
enabled: yes
# Restart a service
- service:
name: nginx
state: restarted
# Stop a service
- service:
name: nginx
state: stopped
# Execute a command
- command: ls -la /tmp
# Execute a shell command (with shell features like pipes)
- shell: cat /etc/os-release | grep PRETTY_NAME
# Run only if a file doesn't exist
- command: /usr/bin/make_database.sh db_user
args:
creates: /etc/database_created # Skip if this file exists
Best Practice: Use command/shell only when no module exists for your task. Modules are more idempotent and provide better error handling.
# Clone a repository
- git:
repo: https://github.com/user/repo.git
dest: /opt/repo
version: main
force: yes # Update to latest even if exists
# Clone with depth (shallow clone)
- git:
repo: https://github.com/user/repo.git
dest: /opt/repo
depth: 1
# Use a Jinja2 template
- template:
src: nginx.conf.j2
dest: /etc/nginx/nginx.conf
owner: root
group: root
mode: '0644'
validate: nginx -t %s
backup: yes
# Find files older than 30 days
- find:
paths: /var/log
age: 30d
file_type: file
register: old_files
# Delete old files
- file:
path: "{{ item.path }}"
state: absent
loop: "{{ old_files.files }}"
Variables and facts make your playbooks flexible and reusable. Facts are system information gathered by Ansible; variables are values you define.
# In playbook
- hosts: webservers
vars:
nginx_port: 80
app_path: /opt/myapp
# In inventory (group_vars)
# inventory
[webservers]
web1.example.com
# group_vars/webservers.yml
---
nginx_port: 80
app_env: production
# host_vars/web1.example.com.yml
---
app_path: /opt/web1
- name: Use variables
hosts: webservers
vars:
port: 8080
tasks:
- name: Display variable
debug:
msg: "Port is {{ port }}"
- name: Set a variable
set_fact:
full_url: "http://example.com:{{ port }}"
Facts are automatically gathered by Ansible (when gather_facts: yes):
- name: Show facts
hosts: all
tasks:
- name: Display OS family
debug:
msg: "OS: {{ ansible_facts['os_family'] }}"
- name: Display hostname
debug:
msg: "Hostname: {{ ansible_hostname }}"
- name: Display IP addresses
debug:
msg: "IPs: {{ ansible_facts['all_ipv4_addresses'] }}"
- name: Display memory
debug:
msg: "Memory: {{ (ansible_facts['memtotal_mb'] / 1024) | round(2) }} GB"
Common fact variables:
ansible_hostname - Short hostnameansible_fqdn - Fully qualified domain nameansible_os_family - OS family (Debian, RedHat, etc.)ansible_distribution - Distribution (Ubuntu, CentOS, etc.)ansible_distribution_version - Distribution versionansible_default_ipv4 - Default network interface infoansible_memtotal_mb - Total memory in MB- name: Register command output
command: ls -la /tmp
register: ls_output
- name: Display output
debug:
var: ls_output.stdout_lines
- name: Check if command changed
debug:
msg: "Command changed: {{ ls_output.changed }}"
- name: Use magic variables
hosts: webservers
tasks:
- name: Show inventory hostname
debug:
msg: "{{ inventory_hostname }}"
- name: Show all hosts in group
debug:
msg: "{{ groups['webservers'] }}"
- name: Hostvars of another host
debug:
msg: "{{ hostvars['db1.example.com']['ansible_facts']['ansible_distribution'] }}"
- name: Use filters
hosts: all
tasks:
- debug:
msg: "{{ nginx_port | default(80) }}"
- debug:
msg: "{{ 'hello' | upper }}"
- debug:
msg: "{{ ['a', 'b', 'c'] | join(',') }}"
- debug:
msg: "{{ 3.14159 | round(2) }}"
- debug:
msg: "{{ ansible_facts['memtotal_mb'] | human_readable }}"
- debug:
msg: "{{ {'a': 1, 'b': 2} | to_nice_json }}"
Conditionals and loops let you create dynamic, flexible playbooks that respond to the state of your systems.
- name: Install nginx on Debian
apt:
name: nginx
state: present
when: ansible_os_family == "Debian"
- name: Install httpd on RedHat
yum:
name: httpd
state: present
when: ansible_os_family == "RedHat"
# Multiple conditions
- name: Install Apache only if it's not already installed
apt:
name: apache2
state: present
when:
- ansible_os_family == "Debian"
- "'apache2' not in ansible_facts.packages"
- name: Check if file exists
command: test -f /etc/special.conf
register: special_file
ignore_errors: yes
- name: Create file if missing
file:
path: /etc/special.conf
state: touch
when: special_file.rc != 0
- name: Install multiple packages
apt:
name: "{{ item }}"
state: present
loop:
- nginx
- vim
- curl
- git
# With index
- name: Print with index
debug:
msg: "{{ index }}: {{ item }}"
loop:
- apple
- banana
loop_control:
index_var: index
- name: Create users
user:
name: "{{ item.name }}"
shell: "{{ item.shell }}"
state: present
loop:
- { name: 'alice', shell: '/bin/bash' }
- { name: 'bob', shell: '/bin/zsh' }
- { name: 'charlie', shell: '/bin/false' }
- name: Create users from dict
user:
name: "{{ item.key }}"
shell: "{{ item.value.shell }}"
state: present
loop: "{{ {'alice': {'shell': '/bin/bash'}, 'bob': {'shell': '/bin/zsh'}}} | dict2items }}"
- name: Install packages only on RedHat
yum:
name: "{{ item }}"
state: present
loop:
- httpd
- vim
- curl
when: ansible_os_family == "RedHat"
- name: Wait for database to start
wait_for:
port: 5432
host: localhost
timeout: 30
register: db_wait
until: db_wait is succeeded
delay: 2
retries: 5
In modern Ansible, prefer loop over with_*:
# Old style (still works but deprecated)
- name: Old style
apt:
name: "{{ item }}"
with_items:
- nginx
- vim
# New style (preferred)
- name: New style
apt:
name: "{{ item }}"
loop:
- nginx
- vim
Roles are the way to organize Ansible content into reusable components. They provide a structured way to package automation content.
roles/
nginx/
βββ defaults/
β βββ main.yml # Default variables (lowest priority)
βββ files/ # Static files to copy
βββ handlers/
β βββ main.yml # Handlers
βββ meta/ # Role metadata
β βββ main.yml
βββ tasks/
β βββ main.yml # Main tasks
βββ templates/ # Jinja2 templates
β βββ nginx.conf.j2
βββ tests/ # Test playbooks
β βββ inventory
β βββ test.yml
βββ vars/
βββ main.yml # Variables (highest priority)
# roles/nginx/defaults/main.yml
---
nginx_port: 80
nginx_server_name: localhost
nginx_workers: 4
# roles/nginx/tasks/main.yml
---
- name: Install nginx
apt:
name: nginx
state: present
when: ansible_os_family == "Debian"
- name: Install nginx
yum:
name: nginx
state: present
when: ansible_os_family == "RedHat"
- name: Copy nginx config
template:
src: nginx.conf.j2
dest: /etc/nginx/nginx.conf
validate: nginx -t %s
notify: restart nginx
- name: Enable nginx
service:
name: nginx
enabled: yes
# roles/nginx/handlers/main.yml
---
- name: restart nginx
service:
name: nginx
state: restarted
# roles/nginx/templates/nginx.conf.j2
worker_processes {{ nginx_workers }};
events {
worker_connections 1024;
}
http {
server {
listen {{ nginx_port }};
server_name {{ nginx_server_name }};
}
}
# site.yml
---
- name: Configure webservers
hosts: webservers
become: yes
roles:
- nginx
# With role variables
- name: Configure webservers
hosts: webservers
become: yes
roles:
- role: nginx
nginx_port: 8080
# roles/nginx/meta/main.yml
---
dependencies:
- role: common
tags: ['base']
- role: logging
when: enable_logging | default(true)
Ansible Galaxy is the community hub for sharing roles:
# Search for roles
ansible-galaxy search nginx
# Install a role
ansible-galaxy install nginx/nginx
# Create a role skeleton
ansible-galaxy init my_role
# List installed roles
ansible-galaxy list
Collections are the newer way to distribute Ansible content:
# Install collection
ansible-galaxy collection install community.general
# Use in playbook
- name: Use collection module
community.general.homematic:
host: 127.0.0.1
vendor: homematic
Ansible uses Jinja2 for templating. Templates let you create configuration files that adapt to different hosts and environments.
# templates/app.conf.j2
# Application configuration
app_name = {{ app_name }}
environment = {{ environment }}
port = {{ app_port }}
[database]
host = {{ db_host }}
port = {{ db_port }}
name = {{ db_name }}
# task
- template:
src: app.conf.j2
dest: /etc/myapp/app.conf
mode: '0640'
owner: root
group: root
# templates/nginx.conf.j2
server {
{% if enable_ssl %}
listen 443 ssl;
ssl_certificate {{ ssl_cert_path }};
ssl_certificate_key {{ ssl_key_path }};
{% else %}
listen 80;
{% endif %}
server_name {{ server_name }};
{% for port in proxy_ports %}
location /{{ port }}/ {
proxy_pass http://localhost:{{ port }}/;
}
{% endfor %}
}
# Using filters in templates
# Convert to uppercase
{{ env | upper }}
# Default value
{{ mysql_port | default(3306) }}
# Join list
{{ domains | join(', ') }}
# First item
{{ users.0.name }}
# Check if defined
{{ nginx_path | default('/etc/nginx', true) }}
# Tests in templates
{% if ansible_facts['os_family'] == 'Debian' %}
# Debian-specific config
{% endif %}
{% if ssl_enabled is defined and ssl_enabled %}
# SSL config
{% endif %}
{% if 'webserver' in group_names %}
# Config for webservers
{% endif %}
# templates/haproxy.cfg.j2
global
log /dev/log local0
log /dev/log local1 notice
maxconn {{ max_connections }}
defaults
log global
mode http
option httplog
option dontlognull
timeout connect 5000
timeout client 50000
timeout server 50000
{% for backend in backends %}
backend {{ backend.name }}
balance {{ backend.balance | default('roundrobin') }}
{% for server in backend.servers %}
server {{ server.name }} {{ server.host }}:{{ server.port }} check
{% endfor %}
{% endfor %}
Ansible Vault encrypts sensitive data within YAML files. Use it for passwords, API keys, certificates, and other secrets.
# Create new encrypted file
ansible-vault create secrets.yml
# Encrypt existing file
ansible-vault encrypt secrets.yml
# View encrypted file
ansible-vault view secrets.yml
# Edit encrypted file
ansible-vault edit secrets.yml
# Decrypt file
ansible-vault decrypt secrets.yml
# Create password file
echo "my_vault_password" > .vault_pass
# Use with ansible-playbook
ansible-playbook site.yml --vault-password-file .vault_pass
# Or configure in ansible.cfg
# [defaults]
# vault_password_file = .vault_pass
# secrets.yml (encrypted)
---
db_password: "supersecretpassword"
api_key: "1234567890abcdef"
ssl_certificate: |
-----BEGIN CERTIFICATE-----
...
-----END CERTIFICATE-----
# In playbook
- name: Use encrypted vars
hosts: databases
vars_files:
- secrets.yml
tasks:
- name: Set database password
mysql_user:
name: appuser
password: "{{ db_password }}"
priv: '*.*:ALL'
# Different passwords for different environments
ansible-vault create prod.yml --vault-password-file prod_vault_pass
ansible-vault create dev.yml --vault-password-file dev_vault_pass
# In playbook
- name: Production
hosts: production
vars_files:
- prod.yml
- name: Development
hosts: development
vars_files:
- dev.yml
# .gitignore
*.vault_pass
secrets.yml
prod_secrets.yml
Ansible stops executing a playbook when a task fails. Error handling lets you control this behavior and recover from failures gracefully.
- name: Try to stop service, but don't fail if it doesn't exist
service:
name: old_service
state: stopped
ignore_errors: yes
- name: Continue even if command fails
command: /opt/scripts/optional_script.sh
register: result
failed_when: false
- name: Block with rescue
block:
- name: Install and configure nginx
apt:
name: nginx
state: present
- name: Copy config
copy:
src: nginx.conf
dest: /etc/nginx/nginx.conf
rescue:
- name: Rollback on failure
apt:
name: nginx
state: absent
- name: Notify about failure
debug:
msg: "Nginx installation failed!"
- name: Always run cleanup
block:
- name: Deploy application
git:
repo: https://github.com/app/repo.git
dest: /opt/app
always:
- name: Clean up temp files
file:
path: /tmp/app_build
state: absent
- name: Check disk space
command: df -h / | tail -1 | awk '{print $5}' | sed 's/%//'
register: disk_usage
failed_when: disk_usage.stdout | int > 90
- name: Conditional failure
command: /usr/local/bin/custom_check.sh
register: result
failed_when:
- result.rc != 0
- "'critical' in result.stdout"
- name: Debug output
debug:
msg: "Variable value is: {{ my_var }}"
verbosity: 2 # Only show with -vvv
- name: Print all facts
debug:
var: ansible_facts
when: ansible_facts is defined
# Using --start-at-task
ansible-playbook site.yml --start-at-task="Install nginx"
# Run in check mode (no changes made)
ansible-playbook site.yml --check
# Check with diff
ansible-playbook site.yml --check --diff
Testing your Ansible code ensures reliability and prevents production issues. Let's explore testing strategies and CI/CD integration.
# Install
pip install ansible-lint
# Run linting
ansible-lint site.yml
# In CI/CD
- name: Lint Ansible
uses: ansible/ansible-lint-action@v1
Molecule tests roles against different instances:
# Install molecule
pip install molecule molecule-docker
# Initialize role with molecule
molecule init role --role-name my_role
# Run tests
cd roles/my_role
molecule test
# Test workflow
molecule create # Create test instance
molecule converge # Run playbook
molecule verify # Run tests
molecule destroy # Clean up
# molecule/default/converge.yml
---
- name: Converge
hosts: all
become: true
roles:
- role: my_role
test_var: "test_value"
# molecule/default/verify.yml
---
- name: Verify
hosts: all
gather_facts: false
tasks:
- name: Check file exists
stat:
path: /etc/myapp/config
register: config_file
- name: Assert config file exists
assert:
that:
- config_file.stat.exists
name: Ansible CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.11'
- name: Install Ansible
run: |
pip install ansible ansible-lint molecule molecule-docker
- name: Lint
run: ansible-lint site.yml
- name: Molecule Test
run: molecule test
# .kitchen.yml
---
driver:
name: docker
provisioner:
name: ansible_playbook
playbook: test/integration/default/test.yml
platforms:
- name: ubuntu-20.04
- name: centos-8
suites:
- name: default
For enterprise deployments, Ansible provides additional tools and patterns for scale, security, and collaboration.
Ansible Tower (Red Hat) and AWX (upstream) provide:
Collections package Playbooks, Roles, Modules, and Plugins:
# requirements.yml
collections:
- name: community.general
- name: ansible.posix
- name: awx.awx
- name: kubernetes.core
# Install
ansible-galaxy collection install -r requirements.yml
Containerized Ansible environments for consistent execution:
# execution-environment.yml
---
version: 1
dependencies:
galaxy: requirements.yml
python: requirements.txt
system: bindep.txt
build_arg: --container-engine docker
infrastructure/
βββ ansible.cfg
βββ inventory/
β βββ production/
β β βββ hosts.yml
β β βββ group_vars/
β βββ development/
β βββ hosts.yml
β βββ group_vars/
βββ playbooks/
β βββ site.yml
β βββ webservers.yml
β βββ databases.yml
βββ roles/
β βββ common/
β βββ nginx/
β βββ postgresql/
βββ collections/
β βββ requirements.yml
βββ library/
Congratulations on completing this guide! You've learned:
Continue your journey with: