Manually configuring a new VPS takes 2–4 hours and introduces human error. Ansible playbooks automate the entire process — installing packages, configuring Nginx, hardening SSH, setting up databases — in minutes, idempotently and repeatably. When you need a second Hong Kong VPS (for a warm standby, a new client environment, or a staging server), running one command reproduces your exact production configuration.
Why Ansible for VPS Automation?
- Agentless — Ansible connects via SSH; no daemon to install on the VPS
- Idempotent — run the same playbook ten times; only necessary changes are applied
- Human-readable — YAML playbooks are documentation and automation in one file
- No central server — run from your laptop or CI/CD pipeline; no Ansible master required
Step 1: Install Ansible on Your Local Machine
# macOS
brew install ansible
# Ubuntu/Debian
apt install -y ansible
# pip (any platform)
pip install ansible --break-system-packages
ansible --version # Should show ansible [core 2.17+]Step 2: Project Structure
mkdir -p ~/ansible-hk-vps/{roles,group_vars,host_vars}
cd ~/ansible-hk-vps
# inventory.ini — your VPS hosts
cat > inventory.ini << 'EOF'
[hk_vps]
hk-prod ansible_host=YOUR_VPS_IP ansible_user=root ansible_port=22
hk-stage ansible_host=YOUR_STAGE_IP ansible_user=root ansible_port=22
[hk_vps:vars]
ansible_ssh_private_key_file=~/.ssh/id_ed25519
ansible_python_interpreter=/usr/bin/python3
EOF
# Test connectivity
ansible all -i inventory.ini -m pingStep 3: Base Server Hardening Role
<code">mkdir -p roles/base/{tasks,handlers,templates,defaults}
cat > roles/base/defaults/main.yml << 'EOF' ssh_port: 22222 admin_user: deploy timezone: Asia/Hong_Kong swap_size_mb: 2048 EOF cat > roles/base/tasks/main.yml << 'EOF'
---
- name: Update apt cache and upgrade packages
apt:
update_cache: yes
upgrade: dist
cache_valid_time: 3600
- name: Install essential packages
apt:
name:
- curl
- wget
- git
- ufw
- fail2ban
- unattended-upgrades
- htop
- vim
- ntp
- logrotate
state: present
- name: Set timezone
timezone:
name: "{{ timezone }}"
- name: Create swap file
command: fallocate -l {{ swap_size_mb }}M /swapfile
args:
creates: /swapfile
- name: Set swap permissions
file:
path: /swapfile
mode: '0600'
- name: Make swap
command: mkswap /swapfile
when: ansible_swaptotal_mb < 1
- name: Enable swap
command: swapon /swapfile
when: ansible_swaptotal_mb < 1 - name: Add swap to fstab lineinfile: path: /etc/fstab line: '/swapfile none swap sw 0 0' state: present - name: Create deploy user user: name: "{{ admin_user }}" shell: /bin/bash groups: sudo append: yes state: present - name: Add SSH key for deploy user authorized_key: user: "{{ admin_user }}" key: "{{ lookup('file', '~/.ssh/id_ed25519.pub') }}" state: present - name: Configure SSH hardening template: src: sshd_config.j2 dest: /etc/ssh/sshd_config validate: /usr/sbin/sshd -t -f %s notify: Restart SSH - name: Configure UFW defaults ufw: direction: "{{ item.direction }}" policy: "{{ item.policy }}" loop: - { direction: incoming, policy: deny } - { direction: outgoing, policy: allow } - name: Allow SSH on custom port ufw: rule: limit port: "{{ ssh_port }}" proto: tcp - name: Allow HTTP and HTTPS ufw: rule: allow port: "{{ item }}" proto: tcp loop: - '80' - '443' - name: Enable UFW ufw: state: enabled - name: Configure sysctl security settings sysctl: name: "{{ item.name }}" value: "{{ item.value }}" state: present reload: yes loop: - { name: net.ipv4.tcp_syncookies, value: '1' } - { name: net.ipv4.conf.all.rp_filter, value: '1' } - { name: kernel.randomize_va_space, value: '2' } - { name: fs.protected_symlinks, value: '1' } - { name: fs.protected_hardlinks, value: '1' } - { name: kernel.dmesg_restrict, value: '1' } EOF cat > roles/base/handlers/main.yml << 'EOF' --- - name: Restart SSH service: name: sshd state: restarted EOF cat > roles/base/templates/sshd_config.j2 << 'EOF'
Port {{ ssh_port }}
PasswordAuthentication no
PermitRootLogin prohibit-password
PubkeyAuthentication yes
MaxAuthTries 3
LoginGraceTime 30
X11Forwarding no
AllowAgentForwarding no
ClientAliveInterval 300
ClientAliveCountMax 3
EOFStep 4: Nginx Role
<code">mkdir -p roles/nginx/{tasks,handlers,templates,defaults}
cat > roles/nginx/defaults/main.yml << 'EOF' domains: [] ssl_email: admin@yourdomain.com EOF cat > roles/nginx/tasks/main.yml << 'EOF' --- - name: Install Nginx and Certbot apt: name: - nginx - certbot - python3-certbot-nginx state: present - name: Enable and start Nginx service: name: nginx state: started enabled: yes - name: Deploy Nginx site configs template: src: site.conf.j2 dest: /etc/nginx/sites-available/{{ item.name }} loop: "{{ domains }}" notify: Reload Nginx - name: Enable site configs file: src: /etc/nginx/sites-available/{{ item.name }} dest: /etc/nginx/sites-enabled/{{ item.name }} state: link loop: "{{ domains }}" notify: Reload Nginx - name: Issue SSL certificates command: >
certbot --nginx -d {{ item.domain }} --non-interactive
--agree-tos --email {{ ssl_email }}
loop: "{{ domains }}"
args:
creates: /etc/letsencrypt/live/{{ item.domain }}/fullchain.pem
EOF
cat > roles/nginx/handlers/main.yml << 'EOF'
---
- name: Reload Nginx
service:
name: nginx
state: reloaded
EOFStep 5: PostgreSQL Role
<code">mkdir -p roles/postgresql/{tasks,defaults}
cat > roles/postgresql/defaults/main.yml << 'EOF' postgresql_version: 16 postgresql_databases: [] postgresql_users: [] postgresql_shared_buffers: "256MB" postgresql_max_connections: 100 EOF cat > roles/postgresql/tasks/main.yml << 'EOF'
---
- name: Add PostgreSQL apt repository
apt_repository:
repo: deb http://apt.postgresql.org/pub/repos/apt {{ ansible_distribution_release }}-pgdg main
state: present
- name: Install PostgreSQL
apt:
name: postgresql-{{ postgresql_version }}
state: present
- name: Ensure PostgreSQL is started
service:
name: postgresql
state: started
enabled: yes
- name: Create PostgreSQL databases
become_user: postgres
postgresql_db:
name: "{{ item.name }}"
state: present
loop: "{{ postgresql_databases }}"
- name: Create PostgreSQL users
become_user: postgres
postgresql_user:
name: "{{ item.name }}"
password: "{{ item.password }}"
db: "{{ item.db }}"
priv: ALL
state: present
loop: "{{ postgresql_users }}"
no_log: true
EOFStep 6: Master Playbook
<code">cat > site.yml << 'EOF' --- - name: Provision Hong Kong VPS hosts: hk_vps become: yes vars_files: - group_vars/all.yml roles: - base - nginx - postgresql post_tasks: - name: Verify services are running service: name: "{{ item }}" state: started loop: - nginx - postgresql - fail2ban - name: Print completion summary debug: msg: | ✅ VPS provisioning complete SSH port: {{ ssh_port }} Deploy user: {{ admin_user }} Nginx: running PostgreSQL: running EOF cat > group_vars/all.yml << 'EOF'
# Base configuration
ssh_port: 22222
admin_user: deploy
timezone: Asia/Hong_Kong
# Nginx
ssl_email: admin@yourdomain.com
domains:
- name: myapp
domain: yourdomain.com
# PostgreSQL
postgresql_databases:
- name: myapp_production
postgresql_users:
- name: myapp_user
password: "{{ vault_db_password }}"
db: myapp_production
EOFAnsible Vault for Secrets
<code"># Never store passwords in plaintext — use Ansible Vault ansible-vault create group_vars/vault.yml # Add: vault_db_password: YourStrongPassword! # Run playbook with vault ansible-playbook -i inventory.ini site.yml --ask-vault-pass # Or with a vault password file (for CI/CD) echo "YourVaultPassword" > ~/.vault_pass chmod 600 ~/.vault_pass ansible-playbook -i inventory.ini site.yml --vault-password-file ~/.vault_pass
Step 7: Deploy Application Updates
<code">cat > deploy.yml << 'EOF'
---
- name: Deploy application update
hosts: hk_vps
become: yes
vars:
app_dir: /var/www/myapp
app_user: deploy
tasks:
- name: Pull latest code
git:
repo: git@github.com:your-org/myapp.git
dest: "{{ app_dir }}"
version: main
force: yes
become_user: "{{ app_user }}"
- name: Install dependencies
command: npm install --production
args:
chdir: "{{ app_dir }}"
become_user: "{{ app_user }}"
- name: Run database migrations
command: npm run migrate
args:
chdir: "{{ app_dir }}"
become_user: "{{ app_user }}"
- name: Restart application
systemd:
name: myapp
state: restarted
daemon_reload: yes
- name: Verify application is healthy
uri:
url: http://127.0.0.1:3000/health
status_code: 200
retries: 5
delay: 3
EOF
# Run deployment
ansible-playbook -i inventory.ini deploy.ymlStep 8: Provision Multiple VPS in Parallel
<code"># Ansible runs tasks on all hosts in parallel by default (forks=5) # Provision 3 HK VPS simultaneously: # inventory.ini # [hk_vps] # hk-prod-1 ansible_host=1.2.3.4 # hk-prod-2 ansible_host=1.2.3.5 # hk-staging ansible_host=1.2.3.6 # Run with 10 parallel forks ansible-playbook -i inventory.ini site.yml -f 10 # Limit to specific host ansible-playbook -i inventory.ini site.yml --limit hk-staging # Check what would change (dry run) ansible-playbook -i inventory.ini site.yml --check --diff
Conclusion
Ansible playbooks turn your Hong Kong VPS setup from a manual 3-hour process into a 10-minute automated run — idempotent, version-controlled, and repeatable. The roles cover base hardening (sysctl, SSH, UFW), Nginx with SSL, and PostgreSQL; additional roles for Redis, Node.js, Docker, or any other component follow the same pattern. With your playbook in Git, every new VPS is provisioned identically, and every configuration change is tracked and peer-reviewable.
Automate your infrastructure: Browse Server.HK Hong Kong VPS plans — provision a new VPS and run your Ansible playbook to have a production-ready server in under 15 minutes.