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.
Working on something similar?
I'm a Technical Lead & AI Engineer building LLM-powered SaaS in production.
NestJS, Next.js and Django on Azure, from model integration to the architecture around it. If your team is working through the same problems, I'm happy to compare notes.
Get in touchWritten by

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