Zero manual SSH sessions. Zero hand-typed commands. One
ansible-playbookcall and the whole stack is live.
A Little Context
As part of my job, spinning up cloud VMs and deploying web applications is routine. But the mandate for my latest project, Cloud-1, was to take this a step further and automate absolutely everything. No clicking around in a dashboard, no executing commands by hand on the server. The entire deployment pipeline had to be flawlessly scripted and repeatable.
The result is a clean DevOps project that touches Ansible, Docker Compose, Nginx, Certbot (Let's Encrypt), DuckDNS, and automated certificate renewal. In this post I'll walk through the design and the most interesting implementation details.
The Architecture at a Glance
Local Machine (control node)
│
│ ansible-playbook
│ ──────────────────────► Cloud VM (Ubuntu)
│ │
│ ├─ Docker
│ │ ├─ WordPress
│ │ ├─ Nginx (reverse-proxy + SSL termination)
│ │ ├─ MariaDB
│ │ └─ Certbot (one-shot certificate issuer)
│ │
│ └─ anacron (monthly cert renewal)
Everything lives in two Ansible playbooks:
| Playbook | Responsibility |
|---|---|
docker_playbook.yaml |
Install Docker & add the ubuntu user to the docker group |
deploy_playbook.yaml |
Clone the app repo, inject secrets, start containers, configure DNS & SSL |
Step 1 — Inventory: Targeting the Servers
The inventory.ini is deliberately minimal:
[myhosts]
15.237.214.130
15.236.239.28
Two IP addresses, one group. Ansible will run every play against both hosts in parallel. Swapping servers is a one-line change.
Step 2 — Bootstrapping Docker (the docker_playbook)
Before we can run any containers, the host needs Docker. Rather than baking a custom AMI, the playbook installs it idempotently every time:
- name: Install Docker
hosts: myhosts
remote_user: ubuntu
become: true
tasks:
- name: Install required system packages
ansible.builtin.apt:
pkg:
- apt-transport-https
- ca-certificates
- curl
- software-properties-common
- git
- anacron # needed later for cert renewal
state: latest
- name: Add Docker GPG apt Key
ansible.builtin.apt_key:
url: https://download.docker.com/linux/ubuntu/gpg
state: present
- name: Add docker repository
ansible.builtin.apt_repository:
repo: "deb [arch=amd64] https://download.docker.com/linux/ubuntu focal stable"
state: present
- name: Install docker
ansible.builtin.apt:
name: docker-ce
state: latest
- name: Start & enable docker service
ansible.builtin.service:
name: docker
state: started
enabled: true
- name: Add the current user to docker group
ansible.builtin.user:
name: ubuntu
groups: docker
append: yes
The key design choice here is idempotency — running this playbook twice won't break anything. state: latest and state: present ensure Ansible only acts when necessary.
Step 3 — Deploying the Application (the deploy_playbook)
This is where the magic happens. The playbook has eight logically ordered stages.
3a. Clone the App Repository
- name: Clone the repository of source files
ansible.builtin.git:
repo: "https://github.com/UBA-code/cloud-1-blog.git"
dest: $HOME/site
force: true
force: true ensures any local changes on the server are wiped and replaced with the canonical version from GitHub. The server is always in sync with the repo — no config drift.
3b. Inject Secrets Securely
- name: Copy the .env into wordpress directory
ansible.builtin.copy:
src: ../secrets/.env
dest: $HOME/site
The .env file lives in a secrets/ directory on the control node (local machine) and is explicitly listed in .gitignore. It is never committed to version control. Ansible copies it to the server at deploy time — so secrets travel over SSH but never touch a git remote.
3c. Start the Containers
- name: Build and run the containers
ansible.builtin.command:
cmd: docker compose up --build -d
chdir: /home/ubuntu/site
become: yes
--build ensures any image changes are picked up. -d puts containers in detached (daemon) mode. One command brings up WordPress, MariaDB, Nginx, and Certbot simultaneously.
3d. Dynamic DNS with DuckDNS
Cloud VMs get a new IP on every boot. Hardcoding IPs in DNS is a maintenance nightmare. The solution: DuckDNS, a free dynamic DNS provider with a dead-simple API.
- name: Update DuckDNS with the new machine IP
ansible.builtin.command: >
curl "https://www.duckdns.org/update?domains=cloud-2&token=<token>&ip=&verbose=true"
register: duckdns_response
- name: Print the DuckDNS response
ansible.builtin.debug:
msg: "{{ duckdns_response.stdout }}"
Leaving ip= empty tells DuckDNS to auto-detect the caller's public IP — perfect for ephemeral cloud instances.
To ensure the DNS stays updated across reboots, the same curl command is appended to .bashrc:
- name: Ensure DuckDNS update is added to .bashrc
ansible.builtin.lineinfile:
path: "$HOME/.bashrc"
line: 'curl "https://www.duckdns.org/update?domains=cloud-2&token=<token>&ip=&verbose=true"'
state: present
create: yes
3e. Generating Let's Encrypt Certificates
HTTPS is non-negotiable. Certbot runs as a Docker container in one-shot mode using the webroot challenge:
- name: Generate Let's Encrypt certificates
ansible.builtin.command:
cmd: >
docker compose run --rm certbot certonly
--webroot --webroot-path=/var/www/certbot
--email you@example.com
--agree-tos --no-eff-email
-d cloud-2.duckdns.org
--non-interactive
chdir: /home/ubuntu/site
register: certbot_output
become: yes
failed_when: 'certbot_output.rc != 0 and "unauthorized" not in certbot_output.stdout'
The failed_when condition is a nice touch: if Certbot exits non-zero because the domain already has a valid certificate ("unauthorized" in Certbot parlance), Ansible doesn't fail the entire play. Idempotency again.
3f. Switching Nginx to HTTPS Mode
The deployment uses a two-phase Nginx config:
-
Initial config (
nginx.conf) — HTTP only, serves the Let's Encrypt challenge at/.well-known/acme-challenge/. -
Final config (
nginx.conf.final) — Full HTTPS with SSL certs mounted as Docker volumes.
Once the certificate is issued, the playbook swaps them:
- name: Switch Nginx config to final SSL version
ansible.builtin.shell:
cmd: "mv nginx.conf nginx.conf.initial && mv nginx.conf.final nginx.conf"
chdir: $HOME/site/nginx-conf/
- name: Reload Nginx
ansible.builtin.command:
cmd: docker compose exec nginx nginx -s reload
chdir: /home/ubuntu/site
when: 'certbot_output.rc == 0 and "unauthorized" not in certbot_output.stdout'
A graceful reload (nginx -s reload) applies the new config with zero downtime.
Step 4 — Automatic Certificate Renewal
Let's Encrypt certificates expire every 90 days. Forgetting to renew means a hard HTTPS outage. The solution is a tiny shell script deployed to the server and wired into anacron:
#!/bin/sh
docker compose -f /home/ubuntu/site/docker-compose.yaml \
run --rm certbot renew > /var/log/renew_cert_log.log
Anacron (unlike cron) is designed for machines that aren't always on — it catches up on missed jobs. The playbook registers a weekly renewal task:
- name: Add certbot renew task to anacron
ansible.builtin.lineinfile:
path: /etc/anacrontab
line: "7 1 cron.renew_task /home/ubuntu/renew_certificate.sh"
state: present
become: true
The 7 means the job runs if it hasn't run in the last 7 days. The logfile at /var/log/renew_cert_log.log captures every renewal attempt for easy debugging.
Project Structure
cloud-1/
├── .gitignore # Excludes secrets/.env
├── inventory.ini # Target hosts
├── playbooks/
│ ├── docker_playbook.yaml # Bootstrap Docker on hosts
│ └── deploy_playbook.yaml # Full application deployment
├── scripts/
│ └── renew_certificate.sh # SSL cert auto-renewal
└── secrets/
└── .env.example # Template — copy to .env and fill in values
Running It
# 1. Configure your servers
vi inventory.ini
# 2. Set up your secrets
cp secrets/.env.example secrets/.env
vi secrets/.env
# 3. Bootstrap Docker on the servers (only needed once)
ansible-playbook -i inventory.ini playbooks/docker_playbook.yaml
# 4. Deploy the full application
ansible-playbook -i inventory.ini playbooks/deploy_playbook.yaml
That's it. A few minutes later, WordPress is live at https://cloud-2.duckdns.org with a valid Let's Encrypt certificate and automatic renewal configured.
Key Takeaways
Infrastructure as Code pays off immediately. When a server was wiped and re-provisioned, bringing it back up was a single command instead of an hour of manual work.
Idempotency is not optional. Every task was written so re-running the playbook is safe. state: present, state: latest, lineinfile with duplicate detection — these aren't conveniences, they're correctness requirements.
Secrets management is simple but effective. No Vault, no KMS at this scale. The pattern of keeping secrets local and copying them over SSH is good enough for most small projects and dramatically reduces the attack surface compared to committing .env files.
Two-phase Nginx config solves the chicken-and-egg SSL problem. You need HTTP available to issue the certificate, but you want HTTPS everywhere after. The swap trick elegantly handles this without a complex conditional setup.
What's Next
A few natural extensions for anyone picking up this project:
-
CI/CD integration — Trigger
deploy_playbook.yamlfrom a GitHub Actions workflow on every push tomain - Monitoring — Add a Prometheus + Grafana stack as additional Docker Compose services
-
Load balancing — Add more hosts to
inventory.iniand put Nginx (or an AWS ALB) in front of them - Backup automation — Dump the MariaDB database and upload it to S3 on a schedule
Cloud-1 is a 42 School project. The full source is on GitHub.
Top comments (0)