Automating Cloud Infrastructure: From Manual Setup to Code-Driven Deployment
If you can't recreate your infrastructure from scratch in under 30 minutes, you have a single point of failure. Ansible lets you describe exactly what a server should look like and apply it repeatedly, identically, across every environment.
Deploying applications manually is error-prone and doesn't scale. Infrastructure as Code (IaC) solves this by treating infrastructure the same way we treat application code — versioned, tested, and repeatable. This guide walks through Cloud-1, a complete IaC project that deploys a WordPress stack on DigitalOcean using Ansible automation and Docker containers.
Prerequisites
Before starting, ensure you have:
- DigitalOcean account with an API token (generate at API → Tokens)
- SSH key added to your DigitalOcean account
- Python 3.8+ installed locally
- Domain name with nameservers pointing to DigitalOcean
- SSL certificate for your domain (or use Let's Encrypt)
Install the required Ansible collection:
# Create virtual environment and install dependencies
python3 -m venv venv
source venv/bin/activate
pip install ansible
# Install DigitalOcean collection
ansible-galaxy collection install community.digitalocean
What is Infrastructure as Code?
Infrastructure as Code (IaC) is the practice of managing and provisioning infrastructure through machine-readable configuration files rather than manual processes. Instead of clicking through cloud consoles, you define your infrastructure in code.
| Manual Process | Infrastructure as Code |
|---|---|
| Click through cloud console UI | Define resources in YAML files |
| No record of what changed | Version controlled in Git |
| Hard to reproduce environments | Identical environments every time |
| Prone to human error | Automated and consistent |
Ansible Fundamentals
Ansible is an agentless automation tool that uses SSH to connect to remote machines and execute tasks. Unlike Chef or Puppet, there's no daemon running on target servers — Ansible pushes configurations from a control node.
How Ansible Works
ANSIBLE EXECUTION MODEL ┌─────────────────┐ ┌─────────────────┐ │ CONTROL NODE │ │ MANAGED NODE │ │ (Your Machine) │ │ (Remote Server) │ │ │ │ │ │ ┌───────────┐ │ SSH + SFTP │ ┌───────────┐ │ │ │ Playbook │──┼───────────────────▶│ │ Python │ │ │ │ (YAML) │ │ │ │ Modules │ │ │ └───────────┘ │ │ └───────────┘ │ │ │ │ │ │ │ │ ▼ │ │ ▼ │ │ ┌───────────┐ │ Execute Tasks │ ┌───────────┐ │ │ │ Inventory │ │◀──────────────────▶│ │ System │ │ │ │ (Hosts) │ │ Return Results │ │ State │ │ │ └───────────┘ │ │ └───────────┘ │ └─────────────────┘ └─────────────────┘
Key Concepts
Inventory: A list of managed nodes (servers). Can be static (INI/YAML file) or dynamic (API-generated).
Playbook: YAML files that define a set of tasks to execute on hosts. Playbooks are human-readable and describe the desired state.
Roles: Reusable units of organization. A role encapsulates tasks, handlers, files, templates, and variables for a specific purpose (e.g., "install nginx").
Idempotency: Running the same playbook multiple times produces the same result. If nginx is already installed, Ansible won't reinstall it — it checks current state first.
# Idempotent task example
- name: Install nginx
apt:
name: nginx
state: present # Only install if not present
# Non-idempotent (avoid this pattern)
- name: Install nginx
command: apt-get install nginx # Runs every time
Task Execution Flow
- Parse: Ansible reads the playbook and inventory
- Connect: Establishes SSH connection to each host
- Transfer: Copies Python modules to remote /tmp
- Execute: Runs modules on the remote host
- Cleanup: Removes temporary files, returns results
SSL/TLS Fundamentals
HTTPS encrypts traffic between clients and servers using TLS (Transport Layer Security). A certificate proves the server's identity, and encryption prevents eavesdropping.
How TLS Handshake Works
TLS HANDSHAKE (Simplified)
CLIENT SERVER
│ │
│──── 1. ClientHello ─────────────────▶│
│ (supported ciphers, TLS version) │
│ │
│◀─── 2. ServerHello ──────────────────│
│ (chosen cipher, certificate) │
│ │
│──── 3. Key Exchange ────────────────▶│
│ (client generates session key) │
│ │
│◀─── 4. Finished ─────────────────────│
│ (encrypted communication begins) │
│ │
│◀═══ 5. Encrypted Data ══════════════▶│
│ │
Certificate Chain
SSL certificates form a chain of trust:
- Root CA: Pre-installed in browsers/OS (e.g., DigiCert, Let's Encrypt)
- Intermediate CA: Signed by Root CA, signs your certificate
- Server Certificate: Your domain's certificate, signed by Intermediate CA
Reverse Proxy Pattern
Nginx acts as a reverse proxy: it terminates TLS (handles encryption) and forwards unencrypted requests to backend services. This centralizes certificate management and offloads crypto from application servers.
REVERSE PROXY WITH TLS TERMINATION
Client Nginx WordPress
│ │ │
│── HTTPS (443) ─────▶│ │
│ (encrypted) │ │
│ │── HTTP (9000) ─────▶│
│ │ (unencrypted) │
│ │ │
│ │◀── Response ────────│
│◀── HTTPS ───────────│ │
│ │ │
└─────── TLS ─────────┘└─── Internal ───────┘
(encrypted) (trusted)
Cloud-1 Architecture
Cloud-1 deploys a complete WordPress stack using Ansible to provision DigitalOcean infrastructure and Docker Compose to orchestrate containerized services.
CLOUD-1 DEPLOYMENT ARCHITECTURE
┌────────────────────────────────────────────────────────────────┐
│ CONTROL NODE │
│ (Local Machine) │
│ │
│ ┌───────────┐ ┌───────────┐ ┌────────────────────────┐ │
│ │ Makefile │──▶│ Ansible │──▶│ DigitalOcean API │ │
│ │ (setup) │ │ Playbook │ │ (Droplet + DNS) │ │
│ └───────────┘ └───────────┘ └────────────────────────┘ │
└────────────────────────────────────────────────────────────────┘
│
│ SSH
▼
┌────────────────────────────────────────────────────────────────┐
│ DIGITALOCEAN DROPLET │
│ Ubuntu 20.04 | 1vCPU | 1GB RAM │
│ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ DOCKER COMPOSE │ │
│ │ │ │
│ │ ┌───────────┐ ┌───────────┐ ┌───────────┐ │ │
│ │ │ nginx │──▶│ wordpress │──▶│ mariadb │ │ │
│ │ │ :443/80 │ │ :9000 │ │ :3306 │ │ │
│ │ │ (SSL/TLS) │ │ (PHP-FPM) │ │ (MySQL) │ │ │
│ │ └───────────┘ └───────────┘ └───────────┘ │ │
│ │ │ │ │ │ │
│ │ └───────────────┼───────────────┘ │ │
│ │ │ │ │
│ │ ┌───────▼───────┐ │ │
│ │ │ my_network │ │ │
│ │ │ (bridge) │ │ │
│ │ └───────────────┘ │ │
│ └──────────────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────────┘
Project Structure
Cloud-1/
├── Makefile # Setup automation
├── inventory.ini # Ansible inventory
├── playbook.yaml # Main Ansible playbook
└── roles/
├── droplet/ # DigitalOcean provisioning
│ └── tasks/main.yml
├── setup/ # Docker installation
│ └── tasks/main.yml
├── database/ # MariaDB container
│ └── tasks/main.yml
├── wordpress/ # WordPress container
│ └── tasks/main.yml
├── webServer/ # Nginx container
│ └── tasks/main.yml
└── Inception/ # Docker Compose setup
└── srcs/
├── docker-compose.yml
├── .env
└── requirements/
├── nginx/
├── wordpress/
└── mariadb/
Ansible Playbook
The playbook orchestrates deployment in two phases: first provisioning cloud infrastructure, then configuring the server with containerized services.
# playbook.yaml
- hosts: server
become: true
roles:
- droplet # Create DigitalOcean droplet and DNS
- hosts: created_droplets
become: true
roles:
- setup # Install Docker and dependencies
- database # Start MariaDB container
- wordpress # Start WordPress container
- webserver # Start Nginx container
Inventory
# inventory.ini
[server]
localhost ansible_connection=local
Droplet Role — Cloud Provisioning
The droplet role creates a DigitalOcean VM and configures DNS records pointing to the new server's IP address.
# roles/droplet/tasks/main.yml
- name: Create a new Droplet
community.digitalocean.digital_ocean_droplet:
state: present
oauth_token: "{{ lookup('ansible.builtin.env', 'DO_API_TOKEN') }}"
name: mydroplet1
unique_name: true
size: s-1vcpu-1gb
region: sfo3
image: ubuntu-20-04-x64
wait_timeout: 500
ssh_keys:
- '29:75:f5:6a:07:49:94:28:e3:ed:a7:60:61:0c:9f:ea'
register: my_droplet
- name: Show Droplet info
ansible.builtin.debug:
msg: |
Droplet ID is {{ my_droplet.data.droplet.id }}
Public IPv4 is {{ (my_droplet.data.droplet.networks.v4 | selectattr('type', 'equalto', 'public')).0.ip_address }}
- name: Add Droplet to Ansible inventory
add_host:
name: "{{ (my_droplet.data.droplet.networks.v4 | selectattr('type', 'equalto', 'public')).0.ip_address }}"
groups: created_droplets
ansible_user: root
ansible_ssh_private_key_file: "~/.ssh/id_rsa"
- name: Add domain to DigitalOcean
community.digitalocean.digital_ocean_domain:
state: present
name: yourdomain.com
oauth_token: "{{ lookup('ansible.builtin.env', 'DO_API_TOKEN') }}"
- name: Add A record for root domain
community.digitalocean.digital_ocean_domain_record:
state: present
domain: yourdomain.com
type: A
name: "@"
data: "{{ (my_droplet.data.droplet.networks.v4 | selectattr('type', 'equalto', 'public')).0.ip_address }}"
oauth_token: "{{ lookup('ansible.builtin.env', 'DO_API_TOKEN') }}"
- name: Add A record for www subdomain
community.digitalocean.digital_ocean_domain_record:
state: present
domain: yourdomain.com
type: A
name: "www"
data: "{{ (my_droplet.data.droplet.networks.v4 | selectattr('type', 'equalto', 'public')).0.ip_address }}"
oauth_token: "{{ lookup('ansible.builtin.env', 'DO_API_TOKEN') }}"
Setup Role — Docker Installation
The setup role installs Docker, Docker Compose, and copies the container configuration to the remote server.
# roles/setup/tasks/main.yml
- name: Wait for apt locks
shell: while fuser /var/lib/dpkg/lock >/dev/null 2>&1; do sleep 1; done
changed_when: false
- name: Install required system packages
apt:
pkg:
- apt-transport-https
- ca-certificates
- curl
- software-properties-common
- python3-pip
- virtualenv
- python3-setuptools
state: latest
update_cache: true
- name: Add Docker GPG key
apt_key:
url: https://download.docker.com/linux/ubuntu/gpg
state: present
- name: Add Docker repository
apt_repository:
repo: deb https://download.docker.com/linux/ubuntu focal stable
state: present
- name: Install Docker-ce
apt:
name: docker-ce
state: latest
update_cache: yes
- name: Install Docker Module for Python
apt:
name: python3-docker
state: present
- name: Install Docker Compose plugin
apt:
name: docker-compose-plugin
state: present
- name: Copy directory to remote host
copy:
src: ../Inception
dest: /
- name: Create Volume Directories
file:
path: /var/data/{{ item }}
state: directory
mode: '0755'
loop:
- "database_volume"
- "wordpress_volume"
Docker Compose — Container Orchestration
Docker Compose defines three services that work together: nginx (reverse proxy), wordpress (PHP application), and mariadb (database).
# docker-compose.yml
version: '3.7'
networks:
my_network:
driver: bridge
volumes:
wordpress_volume:
driver: local
driver_opts:
type: none
device: /var/data/wordpress_volume
o: bind
database_volume:
driver: local
driver_opts:
type: none
device: /var/data/database_volume
o: bind
services:
nginx:
build: ./requirements/nginx
image: nginx
container_name: nginx
restart: always
depends_on:
- wordpress
env_file:
- .env
networks:
- my_network
ports:
- "443:443"
- "80:80"
volumes:
- wordpress_volume:/var/www/html
mariadb:
build: ./requirements/mariadb
image: mariadb
container_name: mariadb
restart: on-failure
env_file:
- .env
networks:
- my_network
ports:
- "3306:3306"
volumes:
- database_volume:/var/lib/mysql
wordpress:
build: ./requirements/wordpress
image: wordpress
container_name: wordpress
restart: on-failure
depends_on:
- mariadb
env_file:
- .env
networks:
- my_network
ports:
- "9000:9000"
volumes:
- wordpress_volume:/var/www/html
Container Dockerfiles
Nginx — Reverse Proxy with SSL
# requirements/nginx/Dockerfile
FROM debian:buster
RUN apt-get -y update && apt-get -y install nginx dumb-init; \
apt-get install -y certbot python3-certbot-nginx;
COPY ./conf/default ./etc/nginx/sites-available/
COPY ./cert/www_yourdomain_com.crt ./etc/ssl/certs/
COPY ./cert/yourdomain.com.key ./etc/ssl/certs/
EXPOSE 443
ENTRYPOINT ["/usr/bin/dumb-init", "/usr/sbin/nginx"]
CMD ["-g", "daemon off;"]
Nginx Configuration
# requirements/nginx/conf/default
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
root /var/www/html/;
index index.php index.html index.htm;
server_name yourdomain.com www.yourdomain.com;
ssl_certificate /etc/ssl/certs/www_yourdomain_com.crt;
ssl_certificate_key /etc/ssl/certs/yourdomain.com.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
location / {
try_files $uri $uri/ /index.php?$args;
}
location ~ \.php$ {
try_files $uri =404;
include /etc/nginx/fastcgi_params;
fastcgi_pass wordpress:9000;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_index index.php;
}
}
WordPress — PHP-FPM Application
# requirements/wordpress/Dockerfile
FROM debian:buster
RUN apt-get -y update && apt-get -y install curl dumb-init; \
apt-get install -y php7.3-fpm php7.3-mysql; \
curl -O https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar; \
chmod +x wp-cli.phar; \
mv wp-cli.phar /usr/local/bin/wp;
EXPOSE 9000
COPY ./tools/script.sh ./
COPY ./conf/www.conf ./etc/php/7.3/fpm/pool.d/
RUN chmod +x script.sh
ENTRYPOINT ["/usr/bin/dumb-init"]
CMD ["./script.sh"]
WordPress Setup Script
# requirements/wordpress/tools/script.sh
#!/bin/bash
cd /var/www/html
# Download WordPress core files
wp core download --path=/var/www/html --allow-root
# Create wp-config.php
cat > wp-config.php << EOF
<?php
define('DB_NAME', '$DB_NAME');
define('DB_USER', '$DB_USER_NAME');
define('DB_PASSWORD', '$DB_USER_PASS');
define('DB_HOST', '$HOST');
define('DB_CHARSET', 'utf8');
define('DB_COLLATE', '');
\$table_prefix = 'wp_';
define('WP_DEBUG', false);
require_once ABSPATH . 'wp-settings.php';
EOF
chown www-data:www-data wp-config.php
chmod 644 wp-config.php
service php7.3-fpm start
# Install WordPress
wp core install --url=$DOMAIN_NAME \
--title="$WP_TITLE" \
--admin_name=$WP_ADMIN_USER \
--admin_password=$WP_ADMIN_PASS \
--admin_email=$WP_ADMIN_EMAIL \
--path=/var/www/html/ \
--allow-root
# Create additional user (optional)
wp user create $WP_USER $WP_USER_EMAIL \
--role=author --user_pass=$WP_USER_PASS --allow-root --path=/var/www/html/
service php7.3-fpm stop
chown -R www-data:www-data /var/www/html
# Start PHP-FPM in foreground
php-fpm7.3 -F
MariaDB — Database Server
# requirements/mariadb/Dockerfile
FROM debian:buster
RUN apt-get -y update && apt-get -y install mariadb-server dumb-init
EXPOSE 3306
COPY ./conf/50-server.cnf ./etc/mysql/mariadb.conf.d/50-server.cnf
COPY ./tools /
RUN chmod +x /script.sh
ENTRYPOINT ["/usr/bin/dumb-init"]
CMD ["./script.sh"]
MariaDB Setup Script
# requirements/mariadb/tools/script.sh
#!/bin/bash
service mysql start
sleep 1
mysql -u root --password="$ROOT_PASSWORD" << EOF
CREATE DATABASE IF NOT EXISTS $DB_NAME;
CREATE USER '$DB_USER_NAME'@'%' IDENTIFIED BY '$DB_USER_PASS';
GRANT ALL PRIVILEGES ON $DB_NAME.* TO '$DB_USER_NAME'@'%';
ALTER USER 'root'@'localhost' IDENTIFIED BY '$ROOT_PASSWORD';
FLUSH PRIVILEGES;
EOF
kill `cat /var/run/mysqld/mysqld.pid`
sleep 1
# Start MySQL in foreground
mysqld
Makefile — Deployment Automation
The Makefile automates the entire deployment process: copying certificates, setting up a Python environment, and running Ansible.
# Makefile
HOME_DIR = $(HOME)
CERT_DIR = roles/Inception/srcs/requirements/nginx/cert
KEY_NAME = yourdomain.com.key
CERT_NAME = www_yourdomain_com.crt
ENV_FILE = env
all: setup
setup: copy-files run-commands
copy-files:
mkdir -p $(CERT_DIR)
cp $(HOME_DIR)/certs/$(KEY_NAME) $(CERT_DIR)/$(KEY_NAME)
cp $(HOME_DIR)/certs/$(CERT_NAME) $(CERT_DIR)/$(CERT_NAME)
cp $(HOME_DIR)/certs/$(ENV_FILE) roles/Inception/srcs/.$(ENV_FILE)
DO_API_TOKEN := $(shell grep DO_API_TOKEN roles/Inception/srcs/.env | cut -d '=' -f2)
run-commands:
python3 -m venv venv && \
. venv/bin/activate && \
pip install ansible && \
export DO_API_TOKEN=$(DO_API_TOKEN) && \
ansible-playbook playbook.yaml -i inventory.ini
Service Startup Roles
After setup, Ansible starts each container in dependency order using Docker Compose.
# roles/database/tasks/main.yml
- name: Start MariaDB using Docker Compose
command: docker-compose -f ../Inception/srcs/docker-compose.yml up -d mariadb
# roles/wordpress/tasks/main.yml
- name: Start WordPress using Docker Compose
command: docker-compose -f ../Inception/srcs/docker-compose.yml up -d wordpress
# roles/webServer/tasks/main.yml
- name: Start Nginx using Docker Compose
command: docker-compose -f ../Inception/srcs/docker-compose.yml up -d nginx
Deployment Workflow
DEPLOYMENT SEQUENCE
make
│
├──▶ copy-files
│ • Copy SSL certificates
│ • Copy .env file
│
└──▶ run-commands
│
├──▶ Create Python venv
├──▶ Install Ansible
└──▶ ansible-playbook
│
├──▶ droplet role (localhost)
│ • Create DigitalOcean droplet
│ • Configure DNS records
│ • Add to dynamic inventory
│
└──▶ configure roles (remote)
│
├──▶ setup: Install Docker
├──▶ database: Start MariaDB
├──▶ wordpress: Start WordPress
└──▶ webserver: Start Nginx
Environment Variables
The .env file contains configuration for all services:
# .env
DO_API_TOKEN=your_digitalocean_api_token
DB_NAME=wordpress
DB_USER_NAME=wp_user
DB_USER_PASS=secure_password
ROOT_PASSWORD=root_secure_password
HOST=mariadb
DOMAIN_NAME=yourdomain.com
WP_TITLE=My WordPress Site
WP_ADMIN_USER=admin
WP_ADMIN_PASS=your_admin_password
WP_ADMIN_EMAIL=admin@yourdomain.com
WP_USER=editor
WP_USER_EMAIL=editor@yourdomain.com
WP_USER_PASS=your_user_password
Security Considerations
Production deployments require additional hardening:
Why dumb-init?
Containers use dumb-init as PID 1 to properly handle signals and reap zombie processes. Without it, your application won't receive SIGTERM correctly during container shutdown.
Firewall Configuration
# Enable UFW and allow only necessary ports
ufw default deny incoming
ufw default allow outgoing
ufw allow 22/tcp # SSH
ufw allow 80/tcp # HTTP (redirect to HTTPS)
ufw allow 443/tcp # HTTPS
ufw enable
Additional Recommendations
- SSH hardening: Disable password authentication, use key-based auth only
- Fail2ban: Protect against brute-force attacks
- Regular updates: Keep base images and packages updated
- Secrets management: Consider using Ansible Vault for sensitive data
- Database access: MariaDB port 3306 should not be exposed publicly
Troubleshooting
| Issue | Solution |
|---|---|
| SSH connection refused | Wait 1-2 minutes for droplet to fully initialize |
| apt lock errors | The setup role waits for locks automatically |
| WordPress can't connect to DB | Ensure MariaDB container is running: docker logs mariadb |
| SSL certificate errors | Verify certificate paths and permissions in nginx container |
| 502 Bad Gateway | Check if PHP-FPM is running: docker logs wordpress |
Conclusion
Cloud-1 demonstrates a complete Infrastructure as Code workflow: from provisioning cloud resources to deploying containerized applications. The combination of Ansible for orchestration and Docker Compose for container management creates a reproducible, version-controlled deployment pipeline.
Key takeaways:
- Two-phase deployment: Provision infrastructure first, then configure it
- Role separation: Each Ansible role handles one concern (droplet, setup, database, etc.)
- Container orchestration: Docker Compose manages service dependencies and networking
- SSL/TLS: Nginx terminates HTTPS and proxies to PHP-FPM
- Environment isolation: Bridge network keeps containers secure
- Automation: Makefile provides single-command deployment
Explore the Code
Check out the Cloud-1 repository on GitHub to see the complete implementation.
Written by

Technical Lead and Full Stack Engineer leading a 5-engineer team at Fygurs (Paris, Remote) on Azure cloud-native SaaS. Graduate of 1337 Coding School (42 Network / UM6P). Writes about architecture, cloud infrastructure, and engineering leadership.