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.

Back to Professional Services
4
App servers, one config
1
Shared bootstrap
443
HTTPS, every cloud
9
Config files

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:

CloudModule wires user_data toEncoding
AWSaws_instance.user_dataplain (provider base64s)
Azureazurerm_linux_virtual_machine.custom_database64encode()
GCPgoogle_compute_instance.metadata_startup_scriptplain
OCIoci_core_instance.metadata.user_database64encode()

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

One Terraform config provisioning four VMs and installing the app on each A single root configuration reads cloud-init.sh once into a local, then calls four child modules — aws, azure, gcp and oci — passing each the same bootstrap as its user_data input. Running terraform apply with parallelism 1 creates one VM in each cloud, and each VM runs the bootstrap on first boot to install and serve the poli-tracker app over HTTPS on port 443. root: app-multi-cloud local.app_bootstrap = file(cloud-init.sh) apply -parallelism=1 module "aws" t3.micro + user_data module "azure" Spot B2ats_v2 + custom_data module "gcp" e2-micro + startup_script module "oci" E2.1.Micro + user_data AWS VM · app on :443 gunicorn HTTPS (self-signed) Azure VM · app on :443 gunicorn HTTPS (self-signed) GCP VM · app on :443 gunicorn HTTPS (self-signed) OCI VM · app on :443 gunicorn HTTPS (self-signed)
The bootstrap is read once into a local and handed to all four modules as 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

FileWhat it's for
versions.tfTerraform + all four provider pins, so init installs them
variables.tfThe few inputs — cloud identifiers plus a shared name and SSH key
main.tfThe shared cloud-init local and the four module blocks, each passing user_data
cloud-init.shThe bootstrap that installs and runs the app — the same script on every cloud
outputs.tfPer-cloud public IPs, app URLs, and SSH commands, as maps keyed by cloud
terraform.tfvars.exampleSample values — copy to terraform.tfvars, set the two required ids (no secrets)
.gitignoreKeeps state, secrets, plugins and terraform.tfvars out of git
list-app-vms.shLists running VMs across all four clouds with each VM's public IP and app URL
README.mdThe 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.

versions.tf
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.

variables.tf
# 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.

main.tf
# 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.

cloud-init.sh
#!/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_urlshttps://<ip> for each cloud — so terraform output app_urls hands you the four dashboards to open.

outputs.tf
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.

terraform.tfvars.example
# 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"
.gitignore
# 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.

set the environment, then apply (always -parallelism=1)
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):

list-app-vms.sh
#!/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.