When you manage more than one server, doing the same setup manually on each one gets old quickly. Install Node.js, configure Nginx, set up the firewall, add your SSH key, create the deploy user — it's the same 20 steps every time.
Ansible solves this by letting you describe your server's desired state in YAML files (called playbooks), then apply that state to any number of servers with a single command. It doesn't require anything to be installed on the target servers — just SSH access and Python (which Ubuntu has by default).
I use it to provision new Lighthouse instances identically in under 5 minutes. Running ansible-playbook provision.yml on a fresh server does everything I'd otherwise do by hand.
The target servers in this guide are Tencent Cloud Lighthouse instances running Ubuntu 22.04. Ansible runs on your local machine or a control node. Lighthouse is a natural fit for Ansible-managed fleets: instances provision from a clean, consistent Ubuntu image in under 2 minutes — each new server is identical, which is exactly what Ansible expects. When you need to add capacity, provision a new Lighthouse instance, run your playbook, and the server is production-ready in minutes. No snowflake configurations.
- Key Takeaways
Ansible fits the "configure once, apply many times" workflow:
Compared to doing things manually: after writing a playbook for your first server, every future server is identical and takes minutes instead of an hour.
The core concepts:
| Concept | What It Is |
|---|---|
| Control node | Your local machine or a CI server that runs Ansible |
| Managed node | Your cloud servers (Lighthouse instances) — no Ansible installed needed |
| Inventory | A list of your servers with their IP addresses and groups |
| Playbook | A YAML file describing what to do on your servers |
| Task | A single action (install a package, create a file, run a command) |
| Module | Built-in Ansible functions for specific tasks (apt, copy, service, etc.) |
| Role | A reusable collection of tasks, variables, and templates |
brew install ansible
sudo apt update
sudo apt install -y software-properties-common
sudo add-apt-repository --yes --update ppa:ansible/ansible
sudo apt install -y ansible
Verify:
ansible --version
mkdir -p ~/ansible/myservers
cd ~/ansible/myservers
Create inventory.ini:
[webservers]
web1 ansible_host=YOUR_SERVER_IP_1 ansible_user=ubuntu
web2 ansible_host=YOUR_SERVER_IP_2 ansible_user=ubuntu
[dbservers]
db1 ansible_host=YOUR_DB_SERVER_IP ansible_user=ubuntu
[all:vars]
ansible_ssh_private_key_file=~/.ssh/id_ed25519
ansible_python_interpreter=/usr/bin/python3
Replace IP addresses with your actual Lighthouse instance IPs.
Create ansible.cfg in your project directory:
[defaults]
inventory = ./inventory.ini
host_key_checking = False
remote_user = ubuntu
private_key_file = ~/.ssh/id_ed25519
host_key_checking = False prevents the "are you sure you want to continue connecting" prompt for new servers. For production environments, you may prefer to set this to True after initial connection.
ansible all -m ping
Expected output:
web1 | SUCCESS => {
"changed": false,
"ping": "pong"
}
web2 | SUCCESS => {
"changed": false,
"ping": "pong"
}
# Check OS info on all web servers
ansible webservers -m command -a "uname -a"
# Get disk usage
ansible all -m command -a "df -h"
# Check uptime
ansible all -m command -a "uptime"
Create hello.yml:
---
- name: My first playbook
hosts: all
become: true # Run as sudo
tasks:
- name: Update apt cache
apt:
update_cache: yes
cache_valid_time: 3600
- name: Install basic tools
apt:
name:
- curl
- wget
- git
- htop
- unzip
state: present
- name: Ensure timezone is UTC
timezone:
name: UTC
- name: Create deploy user
user:
name: deploy
shell: /bin/bash
create_home: yes
state: present
Run it:
ansible-playbook hello.yml
Ansible shows each task as it executes, with ok (no change needed) or changed (something was modified).
Here's the playbook I use to set up a fresh Lighthouse instance as a web server:
Create provision-webserver.yml:
---
- name: Provision web server
hosts: webservers
become: true
vars:
node_version: "20"
app_user: "deploy"
app_dir: "/opt/myapp"
tasks:
# System update
- name: Update and upgrade apt packages
apt:
upgrade: yes
update_cache: yes
# Install system packages
- name: Install required system packages
apt:
name:
- curl
- git
- nginx
- ufw
- fail2ban
- unattended-upgrades
state: present
# Configure UFW firewall
- name: Allow SSH
ufw:
rule: allow
port: '22'
proto: tcp
- name: Allow HTTP
ufw:
rule: allow
port: '80'
proto: tcp
- name: Allow HTTPS
ufw:
rule: allow
port: '443'
proto: tcp
- name: Enable UFW
ufw:
state: enabled
policy: deny
# Install Node.js
- name: Add NodeSource repository
shell: |
curl -fsSL https://deb.nodesource.com/setup_{{ node_version }}.x | bash -
args:
creates: /etc/apt/sources.list.d/nodesource.list
- name: Install Node.js
apt:
name: nodejs
state: present
update_cache: yes
# Install PM2
- name: Install PM2 globally
npm:
name: pm2
global: yes
state: present
# Create deploy user
- name: Create deploy user
user:
name: "{{ app_user }}"
shell: /bin/bash
create_home: yes
- name: Create app directory
file:
path: "{{ app_dir }}"
state: directory
owner: "{{ app_user }}"
group: "{{ app_user }}"
mode: '0755'
# Add SSH key for deploy user
- name: Add authorized SSH key for deploy user
authorized_key:
user: "{{ app_user }}"
key: "{{ lookup('file', '~/.ssh/id_ed25519.pub') }}"
# Configure Nginx
- name: Remove default Nginx site
file:
path: /etc/nginx/sites-enabled/default
state: absent
notify: Reload Nginx
# Enable services
- name: Enable and start Nginx
service:
name: nginx
enabled: yes
state: started
- name: Enable and start Fail2ban
service:
name: fail2ban
enabled: yes
state: started
handlers:
- name: Reload Nginx
service:
name: nginx
state: reloaded
Run against all web servers:
ansible-playbook provision-webserver.yml
Keep configuration separate from tasks:
Create vars/main.yml:
node_version: "20"
app_user: deploy
app_dir: /opt/myapp
app_port: 3000
domain_name: myapp.example.com
Reference in playbook:
vars_files:
- vars/main.yml
Ansible uses Jinja2 for templating. Create a template for Nginx config:
Create templates/nginx.conf.j2:
server {
listen 80;
server_name {{ domain_name }};
location / {
proxy_pass http://localhost:{{ app_port }};
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
Use it in your playbook:
- name: Configure Nginx virtual host
template:
src: templates/nginx.conf.j2
dest: /etc/nginx/sites-available/{{ app_user }}
notify: Reload Nginx
- name: Enable Nginx site
file:
src: /etc/nginx/sites-available/{{ app_user }}
dest: /etc/nginx/sites-enabled/{{ app_user }}
state: link
notify: Reload Nginx
As playbooks grow, organize them into roles:
ansible-galaxy init roles/nodejs
This creates:
roles/nodejs/
├── tasks/main.yml
├── vars/main.yml
├── defaults/main.yml
├── templates/
└── handlers/main.yml
Put Node.js installation steps in roles/nodejs/tasks/main.yml. Reference in your playbook:
- name: Provision servers
hosts: webservers
become: true
roles:
- nodejs
- nginx
- fail2ban
My playbook was working fine for most tasks, but the npm module (used to install PM2 globally) was failing with npm: command not found.
The issue: Ansible's npm module looks for npm in the default PATH, but when you install Node.js from NodeSource, npm is in /usr/bin/npm. The problem was that my Ansible tasks were running in an environment where the PATH wasn't properly set for the newly installed packages.
The fix: Add a meta: flush_handlers task after Node.js installation to ensure the installation fully completes before subsequent tasks run. Or use the shell module for npm installs instead of the npm module:
- name: Install PM2 globally
shell: npm install -g pm2
args:
creates: /usr/bin/pm2
The creates parameter makes this idempotent — it skips the install if the file already exists.
| Issue | Likely Cause | Fix |
|---|---|---|
UNREACHABLE |
SSH connection failed | Check IP, SSH key, and that server is running |
Permission denied |
Wrong SSH key | Verify ansible_ssh_private_key_file in inventory |
| Task not idempotent | Module not tracking state | Add creates, removes, or condition checks |
sudo: a password is required |
Missing become config |
Add become: true to playbook or task |
| Template not rendering | Variable undefined | Check vars_files path and variable names |
| Handler not running | Not notified | Use flush_handlers or check notify names match exactly |
| Package not found | Apt cache stale | Add update_cache: yes to apt tasks |
✅ What you achieved:
Going from a fresh Lighthouse instance to a fully configured web server now takes one command and 5 minutes. Every server in your fleet is configured identically. Changes are tracked in version control.
What's the difference between Ansible and built-in Linux cron?
Linux cron runs commands on a schedule. Ansible provides additional capabilities like visual management, dependency handling, error notifications, and often Docker-native integration.
How do I debug a failing Ansible task?
Check the execution logs first. Verify the command works when run manually with the same user/environment. Common issues: incorrect paths, missing environment variables, permission problems.
How do I make Ansible tasks resilient to failures?
Implement retry logic, alert on failures (email/Slack notification), and log output to a file. For critical tasks, consider writing a simple success/failure status to a monitoring endpoint.
What happens if the server restarts — do scheduled tasks continue?
If configured as a systemd service (as shown in this guide), Ansible restarts automatically on server reboot and resumes its schedule. Configure Restart=on-failure for crash recovery.
systemctl or its own web interface. For important tasks, write output to a log file and use a monitoring tool to check for failures.👉 Get started with Tencent Cloud Lighthouse
👉 View current pricing and launch promotions
👉 Explore all active deals and offers