Professional Services Application Delivery
Multi-cloud app deployment with Terraform & cloud-init
One Terraform configuration that provisions a free-tier VM in all four clouds — AWS,
Azure, GCP and OCI — and installs the same web application on every one, served over HTTPS
on port 443. It composes the four single-cloud VM modules and adds the one new idea on top of bare
provisioning: app installation on first boot, via a shared cloud-init
bootstrap. A single terraform apply -parallelism=1 goes from nothing to four running app
servers; terraform destroy takes it all back down.
What this is
This is the write-up that accompanies the studio's
multi-cloud app guide — a walkthrough of a single configuration
(terraform/app-multi-cloud) that spins up a free-tier VM in each of AWS, Azure, GCP
and OCI and installs a real web application on every one, served over HTTPS on port 443 with a
self-signed certificate. It's the next step up from the bare
multi-cloud VM configuration: same four-module
composition, same -parallelism=1 serialization — plus the application on top.
The deployed app is poli-tracker, a small Flask + SQLite dashboard that tracks U.S. Congress members' disclosed stock purchases and how those positions have performed. It's a stand-in for "your application" — the interesting part here is the deployment: the exact same workload landing on four different clouds' instances from one declarative config.
The one new idea: app install via cloud-init
Every cloud's VM resource can run a bootstrap script on first boot. Each of the
four single-cloud modules was given an optional user_data input and wires it to that
cloud's own mechanism — so the root config can pass the same cloud-init.sh to
all four:
| Cloud | Module wires user_data to | Encoding |
|---|---|---|
| AWS | aws_instance.user_data | plain (provider base64s) |
| Azure | azurerm_linux_virtual_machine.custom_data | base64encode() |
| GCP | google_compute_instance.metadata_startup_script | plain |
| OCI | oci_core_instance.metadata.user_data | base64encode() |
The script detects the package manager (Amazon Linux uses dnf, the others
apt), clones the public repo, installs it, and runs it under gunicorn over HTTPS on
:443. The modules' firewall rules already open 22/80/443, so the app is reachable as soon as the
boot finishes. Because user_data defaults to "", the change is
backward-compatible — the bare VM configs are unaffected.
What it provisions
user_data; each cloud runs it on first boot, so all four VMs come up serving the identical app on :443.The configuration, file by file
| File | What it's for |
|---|---|
versions.tf | Terraform + all four provider pins, so init installs them |
variables.tf | The few inputs — cloud identifiers plus a shared name and SSH key |
main.tf | The shared cloud-init local and the four module blocks, each passing user_data |
cloud-init.sh | The bootstrap that installs and runs the app — the same script on every cloud |
outputs.tf | Per-cloud public IPs, app URLs, and SSH commands, as maps keyed by cloud |
terraform.tfvars.example | Sample values — copy to terraform.tfvars, set the two required ids (no secrets) |
.gitignore | Keeps state, secrets, plugins and terraform.tfvars out of git |
list-app-vms.sh | Lists running VMs across all four clouds with each VM's public IP and app URL |
README.md | The module's own quick-start (this page is the deeper companion) |
versions.tf — all four providers
Identical to the bare multi-cloud config: pin Terraform to 1.5+ and declare all four providers so
terraform init installs them. Each child module configures its own provider.
terraform {
required_version = ">= 1.5.0"
# All four providers are used (one per child module). terraform init installs
# them; each child module configures its own provider from the variables passed
# in below.
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
azurerm = {
source = "hashicorp/azurerm"
version = "~> 4.0"
}
google = {
source = "hashicorp/google"
version = "~> 6.0"
}
oci = {
source = "oracle/oci"
version = "~> 6.0"
}
}
}
variables.tf — just the identifiers
The two required cloud identifiers (GCP project id, OCI compartment OCID), the optional Azure
subscription id, and a shared name + SSH key. The name default is
poli-tracker (vs. multicloud-vm for the bare config), so the app servers
don't collide with a plain multi-cloud deployment.
# Required cloud identifiers (everything else uses the child module defaults).
# No secrets — these are account-scoping identifiers.
variable "gcp_project_id" {
description = "GCP project id. Get it with: gcloud config get-value project"
type = string
}
variable "oci_compartment_id" {
description = "OCI compartment OCID (tenancy root is fine for Always Free). From ~/.oci/config (tenancy=) or: oci iam compartment list"
type = string
}
variable "azure_subscription_id" {
description = "Azure subscription id. Leave null to use the ARM_SUBSCRIPTION_ID env var. Get it with: az account show --query id -o tsv"
type = string
default = null
}
variable "name" {
description = "Name / prefix for the VM and its resources in every cloud."
type = string
default = "poli-tracker"
}
variable "public_key_path" {
description = "Path to the SSH PUBLIC key injected into every VM. The private key stays on your machine."
type = string
default = "~/.ssh/id_rsa.pub"
}
main.tf — the bootstrap local + four module blocks
local.app_bootstrap reads cloud-init.sh once into a string with
file("${path.module}/cloud-init.sh"), so every module gets the identical bootstrap.
Each module block then points at a sibling VM module via a relative source
and passes user_data = local.app_bootstrap alongside that cloud's required inputs —
Azure again getting the billable Spot recipe, OCI using its ssh_public_key_path name.
# Multi-cloud app deployment: one free-tier VM in each of AWS, Azure, GCP and OCI,
# each bootstrapped with the poli-tracker app (cloned from the public repo and
# served on port 80). Reuses the four standalone VM modules, passing the same
# cloud-init bootstrap script to every one via their `user_data` input.
#
# SERIALIZATION / NO RACE CONDITIONS:
# The child modules carry their own provider blocks, so Terraform forbids
# depends_on/count/for_each on them. Run apply/destroy with -parallelism=1 to
# process one operation at a time (no concurrent cross-cloud calls).
locals {
# The same bootstrap runs on every cloud (it detects dnf vs apt internally).
app_bootstrap = file("${path.module}/cloud-init.sh")
}
# --- AWS: t3.micro, Amazon Linux 2023 (free tier), default VPC ---
module "aws" {
source = "../aws-freetier-vm"
name = var.name
public_key_path = var.public_key_path
user_data = local.app_bootstrap
}
# --- Azure: Spot Standard_B2ats_v2 in westcentralus (free B-series is capacity-
# restricted on this subscription, so the working recipe is Spot) ---
module "azure" {
source = "../azure-freetier-vm"
name = var.name
public_key_path = var.public_key_path
subscription_id = var.azure_subscription_id
location = "westcentralus"
vm_size = "Standard_B2ats_v2"
spot_enabled = true
spot_eviction_policy = "Deallocate"
spot_max_bid_price = -1
user_data = local.app_bootstrap
}
# --- GCP: e2-micro in us-west1, Ubuntu 24.04 (Always Free) ---
module "gcp" {
source = "../gcp-freetier-vm"
name = var.name
public_key_path = var.public_key_path
project_id = var.gcp_project_id
user_data = local.app_bootstrap
}
# --- OCI: VM.Standard.E2.1.Micro, Ubuntu 24.04 (Always Free) ---
module "oci" {
source = "../oci-freetier-vm"
name = var.name
ssh_public_key_path = var.public_key_path
compartment_id = var.oci_compartment_id
user_data = local.app_bootstrap
}
cloud-init.sh — install and serve the app on first boot
The heart of the app layer, and the same script on every cloud. It installs Python + git (choosing
dnf on Amazon Linux or apt on Ubuntu), clones the public repo into
/opt/poli-tracker, builds an isolated virtualenv and installs the app plus gunicorn,
does a bounded first data load (CT_MAX_TICKERS=40, with
|| true so a slow fetch can't fail the boot), generates a self-signed TLS cert, and
writes a systemd unit that runs gunicorn over HTTPS on 0.0.0.0:443.
Finally it opens 80/443 in the host firewall — necessary on OCI's Ubuntu images, whose
default iptables rules would otherwise silently drop 443.
#!/bin/bash
# Bootstrap the poli-tracker app on first boot: install Python + git, clone the
# PUBLIC repo, install deps, do an initial data load, and serve it over HTTPS on
# port 443 with a SELF-SIGNED certificate (gunicorn's built-in TLS — no nginx, so
# it works identically on Amazon Linux 2023 (dnf) and Ubuntu (apt)).
#
# Self-signed (not Let's Encrypt) because this deploy isn't always online and DNS
# is hand-managed; browsers show a one-time "unknown issuer" warning.
set -eux
export DEBIAN_FRONTEND=noninteractive
APP_DOMAIN="${APP_DOMAIN:-poli-tracker.rpg4you.com}"
if command -v dnf >/dev/null 2>&1; then
dnf install -y python3 python3-pip git openssl
elif command -v apt-get >/dev/null 2>&1; then
apt-get update -y
apt-get install -y python3 python3-venv python3-pip git openssl
fi
APP_DIR=/opt/poli-tracker
rm -rf "$APP_DIR"
git clone --depth 1 https://github.com/beknar/poli-tracker.git "$APP_DIR"
cd "$APP_DIR"
python3 -m venv .venv
.venv/bin/pip install --upgrade pip
.venv/bin/pip install -r requirements.txt gunicorn
# Initial data load. CT_MAX_TICKERS bounds yfinance work so first boot on a 1 GB
# free-tier VM stays reasonable; '|| true' so a slow/rate-limited fetch doesn't
# fail the boot (the service still starts and the UI's Refresh button can retry).
CT_MAX_TICKERS=40 .venv/bin/python -m backend.ingest || true
# Self-signed TLS cert (domain as CN + SAN, ~10-year validity).
mkdir -p "$APP_DIR/tls"
openssl req -x509 -nodes -newkey rsa:2048 \
-keyout "$APP_DIR/tls/selfsigned.key" \
-out "$APP_DIR/tls/selfsigned.crt" \
-days 3650 -subj "/CN=${APP_DOMAIN}" -addext "subjectAltName=DNS:${APP_DOMAIN}"
chmod 600 "$APP_DIR/tls/selfsigned.key"
# Serve HTTPS directly from gunicorn on :443 (1 worker to fit 1 GB RAM; runs as
# root for the privileged port — fine for this demo).
cat >/etc/systemd/system/poli-tracker.service <<'UNIT'
[Unit]
Description=poli-tracker (gunicorn, self-signed HTTPS)
After=network-online.target
Wants=network-online.target
[Service]
WorkingDirectory=/opt/poli-tracker
Environment=CT_MAX_TICKERS=40
ExecStart=/opt/poli-tracker/.venv/bin/gunicorn -b 0.0.0.0:443 -w 1 --timeout 120 --certfile /opt/poli-tracker/tls/selfsigned.crt --keyfile /opt/poli-tracker/tls/selfsigned.key app:app
Restart=always
[Install]
WantedBy=multi-user.target
UNIT
systemctl daemon-reload
systemctl enable --now poli-tracker
# Open the HOST firewall for 443 (and 80). OCI's Ubuntu images ship an iptables
# firewall that only permits SSH, so without this 443 is silently dropped
# (browser timeout). Harmless on AWS/Azure/GCP images (INPUT defaults to ACCEPT).
if command -v iptables >/dev/null 2>&1; then
for p in 80 443; do
iptables -C INPUT -p tcp --dport "$p" -j ACCEPT 2>/dev/null \
|| iptables -I INPUT -p tcp --dport "$p" -j ACCEPT
done
command -v netfilter-persistent >/dev/null 2>&1 && netfilter-persistent save || true
fi
# Oracle Linux / RHEL-family images use firewalld instead.
if command -v firewall-cmd >/dev/null 2>&1 && firewall-cmd --state >/dev/null 2>&1; then
firewall-cmd --permanent --add-service=https
firewall-cmd --permanent --add-service=http
firewall-cmd --reload
fi
outputs.tf — IPs, app URLs and SSH per cloud
Three maps keyed by cloud. The new one is app_urls — https://<ip>
for each cloud — so terraform output app_urls hands you the four dashboards to open.
output "public_ips" {
description = "Public IP of the VM in each cloud."
value = {
aws = module.aws.public_ip
azure = module.azure.public_ip
gcp = module.gcp.public_ip
oci = module.oci.public_ip
}
}
output "app_urls" {
description = "URL where the poli-tracker app is served in each cloud (HTTPS :443, self-signed cert)."
value = {
aws = "https://${module.aws.public_ip}"
azure = "https://${module.azure.public_ip}"
gcp = "https://${module.gcp.public_ip}"
oci = "https://${module.oci.public_ip}"
}
}
output "ssh_commands" {
description = "Ready-to-run SSH command for each cloud's VM."
value = {
aws = module.aws.ssh_command
azure = module.azure.ssh_command
gcp = module.gcp.ssh_command
oci = module.oci.ssh_command
}
}
terraform.tfvars.example & .gitignore — safe by default
Copy the example to terraform.tfvars and set the two required identifiers — no secrets
belong in it. State and your local terraform.tfvars are gitignored.
# Copy to terraform.tfvars and set the two required identifiers. No secrets here.
# terraform.tfvars is gitignored.
# REQUIRED
gcp_project_id = "my-project-123456" # gcloud config get-value project
oci_compartment_id = "ocid1.tenancy.oc1..xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" # ~/.oci/config tenancy=, or oci iam compartment list
# OPTIONAL — leave unset to use the ARM_SUBSCRIPTION_ID env var instead
# azure_subscription_id = "00000000-0000-0000-0000-000000000000" # az account show --query id -o tsv
name = "poli-tracker"
public_key_path = "~/.ssh/id_rsa.pub"
# Never commit Terraform state (can contain resource details / sensitive values),
# local provider plugins, crash logs, plan files, or your local variable choices
# (terraform.tfvars holds your project id / compartment OCID).
.terraform/
.terraform.lock.hcl
*.tfstate
*.tfstate.*
crash.log
*.tfplan
terraform.tfvars
*.auto.tfvars
No secrets in the repo. Each cloud authenticates the same way it does standalone — AWS CLI profile, az login, GCP Application Default Credentials, OCI config profile. The app is cloned from a public repo, the TLS cert is generated on the box at boot, and the only values you supply are account-scoping identifiers in a gitignored terraform.tfvars.
The apply workflow
Set the cloud identifiers as environment variables (pulled straight from your already-configured
CLIs — no hand-editing), then init once and apply with
-parallelism=1. After apply, give each VM a minute or two to finish cloud-init before
the URL responds.
cd terraform/app-multi-cloud
# pull the required ids straight from your configured CLIs
export TF_VAR_gcp_project_id="$(gcloud config get-value project)"
export TF_VAR_oci_compartment_id="$(grep '^tenancy=' ~/.oci/config | cut -d= -f2)"
export ARM_SUBSCRIPTION_ID="$(az account show --query id -o tsv)"
# AWS needs nothing — it uses the `freetier` profile in ~/.aws/credentials
terraform init # download the four providers, link the modules
terraform fmt -check -recursive # optional: canonical style
terraform plan # preview: VMs + networking across all four clouds
terraform apply -parallelism=1 # create everything, one operation at a time
# give each VM a minute or two to finish cloud-init, then:
terraform output app_urls # the four dashboard URLs (https://<ip>)
./list-app-vms.sh # name + IP + app URL per cloud
terraform destroy -parallelism=1 # tear it all down
Self-signed HTTPS, and one billable VM. The app is served with a self-signed cert (the deploy isn't always online and DNS is hand-managed, so Let's Encrypt adds no value) — browsers show a one-time "unknown issuer" warning. The AWS, GCP and OCI VMs are ~$0 on their free tiers; the Azure VM is a billable Spot instance. Each VM keeps its own SQLite database — independent app servers, ideal behind a load balancer — so terraform destroy -parallelism=1 when you're done.
Listing the app across the four clouds
A helper lists the running VMs in every cloud with each VM's public IP and the app URL in the same view (for OCI it resolves the public IP via the instance's VNIC):
#!/usr/bin/env bash
# List the running app VMs in all four clouds, with each VM's public IP and the
# URL the poli-tracker app is served on (https://<ip>, port 443).
# Requires each cloud's CLI authenticated and on PATH. OCI compartment is read
# from ~/.oci/config (tenancy root).
set -u
line() { printf ' %-22s %-16s %s\n' "$1" "$2" "$3"; }
echo "=== AWS (EC2, profile ${AWS_PROFILE:-freetier}) ==="
line "NAME" "PUBLIC IP" "APP URL"
aws ec2 describe-instances --profile "${AWS_PROFILE:-freetier}" \
--filters Name=instance-state-name,Values=running \
--query 'Reservations[].Instances[].[Tags[?Key==`Name`]|[0].Value, PublicIpAddress]' \
--output text 2>/dev/null \
| while read -r name ip; do line "${name:-?}" "${ip:-none}" "https://${ip}"; done
echo "=== Azure ==="
line "NAME" "PUBLIC IP" "APP URL"
az vm list -d --query "[?powerState=='VM running'].[name, publicIps]" -o tsv 2>/dev/null \
| while read -r name ip; do line "${name:-?}" "${ip:-none}" "https://${ip}"; done
echo "=== GCP ==="
line "NAME" "PUBLIC IP" "APP URL"
gcloud compute instances list --filter='status=RUNNING' \
--format='value(name, networkInterfaces[0].accessConfigs[0].natIP)' 2>/dev/null \
| while read -r name ip; do line "${name:-?}" "${ip:-none}" "https://${ip}"; done
echo "=== OCI ==="
line "NAME" "PUBLIC IP" "APP URL"
TENANCY=$(awk -F= '/^tenancy=/{print $2}' ~/.oci/config 2>/dev/null)
oci compute instance list --compartment-id "$TENANCY" --lifecycle-state RUNNING \
--query 'data[].id' --raw-output 2>/dev/null \
| grep -oE 'ocid1\.instance\.[a-z0-9.]+' \
| while read -r iid; do
name=$(oci compute instance get --instance-id "$iid" \
--query 'data."display-name"' --raw-output 2>/dev/null)
ip=$(oci compute instance list-vnics --instance-id "$iid" \
--query 'data[0]."public-ip"' --raw-output 2>/dev/null)
line "${name:-?}" "${ip:-none}" "https://${ip}"
done
This builds directly on the bare multi-cloud VM
configuration — the per-cloud detail lives in the
AWS, Azure,
GCP and OCI write-ups. The
same fleet can then be patched and hardened with the
Ansible playbooks, and a push to main shipped
through the CI/CD pipeline. Terraform provisions the boxes;
cloud-init brings the app up; Ansible keeps them configured; Jenkins ships to them.
Want one config that provisions the servers and ships the app to all of them?
Reproducible, reviewable, multi-cloud deployment you can stand up and tear down on demand — let's talk.