Examples
solo-web
One Linux web server, as a container or a native VM: the page answers on web:80. A single machine on a single network, so every cloud-vm driver can take it (DigitalOcean and Linode included); the richer examples are multi-machine and those two decline them.
Tested: Generated and validated in CI for every cloud-vm driver (a single machine on a single network).
version: 1
name: solo-web
networks:
app: { cidr: 10.80.0.0/24, internet: true }
machines:
web:
networks: { app: 10 }
services: [{ port: 80, http: true, publish: 8080 }]
docker:
build: build/web
vm:
os: debian-12
provision: [provision/web.sh]
checks:
- checks/web-answers.sh
Targets
docker, hosted, docker-vm, cloud-docker, kubernetes, vagrant, proxmox, cloud-vm. See Targets for what each means.
Checks
The spec's own:
checks/web-answers.sh(a script)
Plus the checks Isoloom derives from its services and reach rules, run from every machine. See Checks.
Generated files
What isoloom generate writes for this spec, as committed next to it. Don't edit them: change
the spec and generate again (isoloom check fails in CI when they're out of date).
.isoloom/docker/compose.yml
# Generated by isoloom from isoloom.yml. Don't edit: change isoloom.yml and run
# `isoloom generate`. `isoloom check` fails when this file is out of date.
# Start: docker compose -f .isoloom/docker/compose.yml up -d --wait
# Checks: isoloom test docker (or: docker compose -f .isoloom/docker/compose.yml --profile check run --rm isoloom-check, and isoloom-check-<machine>)
# Stop: docker compose -f .isoloom/docker/compose.yml down -v
name: solo-web
services:
web:
build:
context: ../../build/web
image: isoloom/solo-web-web
platform: linux/amd64
hostname: web
networks:
app:
ipv4_address: 10.80.0.10
ports:
- ${ISOLOOM_PUBLISH_ADDRESS:-127.0.0.1}:${ISOLOOM_PUBLISH_FIXED:+8080}:80
healthcheck:
test:
- CMD-SHELL
- (nc -z 127.0.0.1 80 2>/dev/null || bash -c '</dev/tcp/127.0.0.1/80' 2>/dev/null)
interval: 5s
timeout: 3s
retries: 60
start_period: 10s
labels:
isoloom.service.80: http:80
restart: unless-stopped
isoloom-check:
image: curlimages/curl:8.11.1
profiles:
- check
entrypoint:
- /bin/sh
- /isoloom/run.sh
volumes:
- ./checks/networks.sh:/isoloom/run.sh:ro
- ../..:/isoloom/project:ro
networks:
app: null
depends_on:
web:
condition: service_healthy
networks:
app:
ipam:
config:
- subnet: 10.80.0.0/24
gateway: 10.80.0.1
.isoloom/vagrant/Vagrantfile
# Generated by isoloom from isoloom.yml. Don't edit: change isoloom.yml and run
# `isoloom generate`. `isoloom check` fails when this file is out of date.
# Start: cd .isoloom/vagrant && vagrant up
# Stop: cd .isoloom/vagrant && vagrant destroy -f
ROOT = File.expand_path("../..", __dir__)
# Copied into each VM: the project, without version control or generated files.
PROJECT = Dir.children(ROOT).reject { |e| [".git", ".isoloom", ".vagrant"].include?(e) }.sort
# ESXi (vagrant-vmware-esxi): ESXI_VIRTUAL_NETWORK lists port groups, comma-separated: the
# management network first, then one per network, in order (app); a missing one reuses the last.
ESXI_NETWORKS = ENV.fetch("ESXI_VIRTUAL_NETWORK", "VM Network").split(",").map(&:strip)
LAB_NETWORKS = ["app"]
def esxi_networks(nets)
[ESXI_NETWORKS[0]] + nets.map { |n| ESXI_NETWORKS[1 + LAB_NETWORKS.index(n)] || ESXI_NETWORKS[-1] }
end
Vagrant.configure("2") do |config|
config.vm.synced_folder ".", "/vagrant", disabled: true
config.vm.boot_timeout = 900
config.vm.define "web" do |m|
m.vm.box = "bento/debian-12"
m.vm.hostname = "web"
m.vm.network "private_network", ip: "10.80.0.10", netmask: "255.255.255.0", virtualbox__intnet: "isoloom-solo-web-app", libvirt__network_name: "isoloom-solo-web-app", libvirt__dhcp_enabled: false
m.vm.network "forwarded_port", guest: 80, host: 8080, host_ip: "127.0.0.1"
m.vm.provider "virtualbox" do |v|
v.name = "solo-web · web"
v.linked_clone = true
v.cpus = 1
v.memory = 1024
end
m.vm.provider "vmware_desktop" do |v|
v.vmx["displayName"] = "solo-web · web"
v.vmx["numvcpus"] = "1"
v.vmx["memsize"] = "1024"
end
m.vm.provider "parallels" do |v|
v.name = "solo-web · web"
v.linked_clone = true
v.cpus = 1
v.memory = 1024
end
m.vm.provider "utm" do |v|
v.name = "solo-web · web"
v.cpus = 1
v.memory = 1024
end
m.vm.provider "qemu" do |v|
v.smp = "cpus=1"
v.memory = "1024M"
end
m.vm.provider "vmware_esxi" do |v|
v.esxi_hostname = ENV.fetch("ESXI_HOSTNAME", "")
v.esxi_hostport = ENV.fetch("ESXI_HOSTPORT", "22").to_i
v.esxi_username = ENV.fetch("ESXI_USERNAME", "root")
v.esxi_password = "env:ESXI_PASSWORD"
v.esxi_disk_store = ENV["ESXI_DATASTORE"] if ENV["ESXI_DATASTORE"]
v.esxi_virtual_network = esxi_networks(["app"])
v.guest_name = "solo-web-web"
v.guest_numvcpus = 1
v.guest_memsize = 1024
end
m.vm.provider "libvirt" do |v, o|
o.vm.box = "generic/debian12"
v.cpus = 1
v.memory = 1024
end
PROJECT.each do |entry|
m.vm.provision "file", source: File.join(ROOT, entry), destination: "/tmp/isoloom-project/#{entry}"
end
m.vm.provision "shell", name: "project", inline: "rm -rf /opt/isoloom && mv /tmp/isoloom-project /opt/isoloom"
m.vm.provision "shell", name: "provision/web.sh", inline: "cd /opt/isoloom && sh provision/web.sh"
end
config.vm.define "isoloom-controller" do |m|
m.vm.box = "bento/debian-12"
m.vm.hostname = "isoloom-controller"
m.vm.network "private_network", ip: "10.80.0.253", netmask: "255.255.255.0", virtualbox__intnet: "isoloom-solo-web-app", libvirt__network_name: "isoloom-solo-web-app", libvirt__dhcp_enabled: false
m.vm.provider "virtualbox" do |v|
v.name = "solo-web · controller"
v.linked_clone = true
v.cpus = 1
v.memory = 1024
end
m.vm.provider "vmware_desktop" do |v|
v.vmx["displayName"] = "solo-web · controller"
v.vmx["numvcpus"] = "1"
v.vmx["memsize"] = "1024"
end
m.vm.provider "parallels" do |v|
v.name = "solo-web · controller"
v.linked_clone = true
v.cpus = 1
v.memory = 1024
end
m.vm.provider "libvirt" do |v, o|
o.vm.box = "generic/debian12"
v.cpus = 1
v.memory = 1024
end
m.vm.provider "vmware_esxi" do |v|
v.esxi_hostname = ENV.fetch("ESXI_HOSTNAME", "")
v.esxi_hostport = ENV.fetch("ESXI_HOSTPORT", "22").to_i
v.esxi_username = ENV.fetch("ESXI_USERNAME", "root")
v.esxi_password = "env:ESXI_PASSWORD"
v.esxi_disk_store = ENV["ESXI_DATASTORE"] if ENV["ESXI_DATASTORE"]
v.esxi_virtual_network = esxi_networks(["app"])
v.guest_name = "solo-web-controller"
v.guest_numvcpus = 1
v.guest_memsize = 1024
end
m.vm.provision "shell", name: "hosts", inline: "for l in '10.80.0.10 web'; do grep -qxF \"$l\" /etc/hosts || echo \"$l\" >> /etc/hosts; done"
PROJECT.each do |entry|
m.vm.provision "file", source: File.join(ROOT, entry), destination: "/tmp/isoloom-project/#{entry}"
end
m.vm.provision "shell", name: "project", inline: "rm -rf /opt/isoloom && mv /tmp/isoloom-project /opt/isoloom"
m.vm.provision "shell", name: "controller", inline: <<~'SH'
set -e
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq
apt-get install -y -qq python3-venv sshpass curl netcat-openbsd >/dev/null
[ -x /opt/ansible/bin/ansible-playbook ] || { python3 -m venv /opt/ansible && /opt/ansible/bin/pip install -q 'ansible-core>=2.15,<2.17' pywinrm; }
mkdir -p /etc/isoloom
cat > /etc/isoloom/inventory.ini <<'INV'
[linux]
web ansible_host=10.80.0.10
[windows]
[linux:vars]
ansible_user=vagrant
ansible_password=vagrant
ansible_become=true
[windows:vars]
ansible_user=vagrant
ansible_password=vagrant
ansible_connection=winrm
ansible_winrm_server_cert_validation=ignore
ansible_winrm_operation_timeout_sec=400
ansible_winrm_read_timeout_sec=500
INV
SH
m.vm.provision "shell", name: "checks", run: "never", path: "checks/controller.sh", env: { "ISOLOOM_DERIVED" => ENV.fetch("ISOLOOM_DERIVED", "1") }
end
end
.isoloom/docker-vm/Vagrantfile
# Generated by isoloom from isoloom.yml. Don't edit: change isoloom.yml and run
# `isoloom generate`. `isoloom check` fails when this file is out of date.
# Start: cd .isoloom/docker-vm && vagrant up
# Checks: cd .isoloom/docker-vm && vagrant provision --provision-with checks
# Stop: cd .isoloom/docker-vm && vagrant destroy -f
ROOT = File.expand_path("../..", __dir__)
# Copied into the VM: the project, its generated Compose file included.
PROJECT = Dir.children(ROOT).reject { |e| [".git", ".vagrant"].include?(e) }.sort
Vagrant.configure("2") do |config|
config.vm.box = "bento/debian-12"
config.vm.hostname = "solo-web"
config.vm.synced_folder ".", "/vagrant", disabled: true
config.vm.boot_timeout = 600
config.vm.network "forwarded_port", guest: 8080, host: 8080, host_ip: "127.0.0.1"
config.vm.provider "virtualbox" do |v|
v.name = "solo-web · docker"
v.cpus = 2
v.memory = 2048
end
config.vm.provider "vmware_desktop" do |v|
v.vmx["displayName"] = "solo-web · docker"
v.vmx["numvcpus"] = "2"
v.vmx["memsize"] = "2048"
end
config.vm.provider "parallels" do |v|
v.name = "solo-web · docker"
v.cpus = 2
v.memory = 2048
end
config.vm.provider "libvirt" do |v, o|
o.vm.box = "generic/debian12"
v.cpus = 2
v.memory = 2048
end
config.vm.provider "vmware_esxi" do |v|
v.esxi_hostname = ENV.fetch("ESXI_HOSTNAME", "")
v.esxi_hostport = ENV.fetch("ESXI_HOSTPORT", "22").to_i
v.esxi_username = ENV.fetch("ESXI_USERNAME", "root")
v.esxi_password = "env:ESXI_PASSWORD"
v.esxi_disk_store = ENV["ESXI_DATASTORE"] if ENV["ESXI_DATASTORE"]
v.esxi_virtual_network = [ENV.fetch("ESXI_VIRTUAL_NETWORK", "VM Network").split(",").first.strip]
v.guest_name = "solo-web-docker"
v.guest_numvcpus = 2
v.guest_memsize = 2048
end
config.vm.provision "shell", name: "docker", inline: "command -v docker >/dev/null || curl -fsSL https://get.docker.com | sh"
PROJECT.each do |entry|
config.vm.provision "file", source: File.join(ROOT, entry), destination: "/tmp/isoloom-project/#{entry}"
end
config.vm.provision "shell", name: "project", inline: "rm -rf /opt/isoloom && mv /tmp/isoloom-project /opt/isoloom"
config.vm.provision "shell", name: "environment", inline: "cd /opt/isoloom && ISOLOOM_PUBLISH_ADDRESS=0.0.0.0 ISOLOOM_PUBLISH_FIXED=1 docker compose -f .isoloom/docker/compose.yml up -d --build --wait --wait-timeout 900 && mkdir -p /var/lib/isoloom && echo ready > /var/lib/isoloom/ready"
config.vm.provision "shell", name: "checks", run: "never", inline: "cd /opt/isoloom && failed=0; for s in $(docker compose -f .isoloom/docker/compose.yml --profile check config --services | grep '^isoloom-check'); do docker compose -f .isoloom/docker/compose.yml --profile check run --rm -e ISOLOOM_DERIVED \"$s\" || failed=1; done; exit $failed", env: { "ISOLOOM_DERIVED" => ENV.fetch("ISOLOOM_DERIVED", "1") }
end
.isoloom/proxmox/main.tf
# Generated by isoloom from isoloom.yml. Don't edit: change isoloom.yml and run
# `isoloom generate`. `isoloom check` fails when this file is out of date.
# Start: terraform -chdir=.isoloom/proxmox init && terraform -chdir=.isoloom/proxmox apply -var proxmox_endpoint=https://<server>:8006/ -var proxmox_api_token=<user@realm!name=secret>
# Stop: terraform -chdir=.isoloom/proxmox destroy (same variables)
terraform {
required_version = ">= 1.6"
backend "local" {}
required_providers {
proxmox = {
source = "bpg/proxmox"
version = "~> 0.115"
}
# The controller's SSH key (environment-level provisioning).
tls = {
source = "hashicorp/tls"
version = "~> 4.0"
}
}
}
variable "proxmox_endpoint" {
type = string
description = "https://<server>:8006/"
}
variable "proxmox_api_token" {
type = string
default = ""
sensitive = true
description = "user@realm!name=secret; or use proxmox_username and proxmox_password"
}
variable "proxmox_username" {
type = string
default = "root@pam"
}
variable "proxmox_password" {
type = string
default = ""
sensitive = true
}
variable "proxmox_insecure" {
type = bool
default = true
description = "Accept the server's self-signed certificate"
}
variable "proxmox_ssh_username" {
type = string
default = "root"
description = "Uploading cloud-init snippets goes over SSH to the node"
}
variable "proxmox_ssh_private_key_file" {
type = string
default = ""
}
variable "proxmox_ssh_address" {
type = string
default = ""
description = "The node's SSH address, when the API reports one this machine can't reach"
}
variable "node" {
type = string
default = "pve"
}
variable "datastore" {
type = string
default = "local-lvm"
description = "Where VM disks go"
}
variable "image_datastore" {
type = string
default = "local"
description = "A datastore with 'iso' content, for cloud images"
}
variable "snippets_datastore" {
type = string
default = "local"
description = "A datastore with 'snippets' content, for cloud-init"
}
variable "uplink_bridge" {
type = string
default = "vmbr0"
description = "The bridge the router reaches the internet through"
}
variable "slot" {
type = number
default = 1
description = "1 to 99, unique per environment on this server (SDN ids are short)"
}
variable "ssh_public_key" {
type = string
default = ""
description = "Installed for the user `isoloom` on every VM"
}
provider "proxmox" {
endpoint = var.proxmox_endpoint
api_token = var.proxmox_api_token != "" ? var.proxmox_api_token : null
username = var.proxmox_api_token != "" ? null : var.proxmox_username
password = var.proxmox_api_token != "" ? null : var.proxmox_password
insecure = var.proxmox_insecure
ssh {
agent = false
username = var.proxmox_ssh_username
password = var.proxmox_ssh_private_key_file != "" ? null : var.proxmox_password
private_key = var.proxmox_ssh_private_key_file != "" ? file(var.proxmox_ssh_private_key_file) : null
dynamic "node" {
for_each = var.proxmox_ssh_address == "" ? [] : [1]
content {
name = var.node
address = var.proxmox_ssh_address
}
}
}
}
locals {
zone = "iso${var.slot}"
# The project, written to /opt/isoloom in each machine that has steps.
root = abspath("${path.module}/../..")
project = [for f in fileset(local.root, "**") : f if !startswith(f, ".git/") && !startswith(f, ".isoloom/") && !startswith(f, ".vagrant/")]
project_files = [for f in local.project : {
path = "/opt/isoloom/${f}"
encoding = "b64"
content = filebase64("${local.root}/${f}")
}]
users = var.ssh_public_key == "" ? [] : [{
name = "isoloom"
sudo = "ALL=(ALL) NOPASSWD:ALL"
shell = "/bin/bash"
ssh_authorized_keys = [var.ssh_public_key]
}]
}
# The environment's networks: an SDN simple zone, a VNet per network.
resource "proxmox_sdn_zone_simple" "env" {
id = local.zone
nodes = [var.node]
}
# Network `app`: 10.80.0.0/24
resource "proxmox_sdn_vnet" "app" {
id = "i${var.slot}n0"
zone = proxmox_sdn_zone_simple.env.id
}
resource "proxmox_sdn_applier" "env" {
depends_on = [proxmox_sdn_vnet.app]
}
resource "proxmox_download_file" "debian_12" {
node_name = var.node
datastore_id = var.image_datastore
content_type = "iso"
url = "https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-genericcloud-amd64.qcow2"
# Per environment: a shared file would go with whichever environment is destroyed first.
file_name = "iso${var.slot}-debian-12.img"
overwrite_unmanaged = true
}
# The router: forwards between networks with the reach rules, and to the internet.
resource "proxmox_virtual_environment_file" "router" {
node_name = var.node
datastore_id = var.snippets_datastore
content_type = "snippets"
source_raw {
file_name = "iso${var.slot}-router.yaml"
data = "#cloud-config\n${yamlencode({
hostname = "isoloom-router"
users = local.users
packages = ["nftables", "qemu-guest-agent"]
write_files = [
{ path = "/etc/systemd/network/10-wan.network", content = join("\n", ["[Match]", "MACAddress=${format("02:15:%02x:00:00:00", var.slot)}", "", "[Network]", "DHCP=yes"]) },
{ path = "/etc/nftables.conf", content = "flush ruleset\ntable inet isoloom {\n chain forward {\n type filter hook forward priority 0; policy drop;\n ct state established,related accept\n ip daddr 10.80.0.10 tcp dport 80 ct status dnat accept\n ip saddr { 10.80.0.0/24 } ip daddr != { 10.80.0.0/24 } accept\n }\n chain prerouting {\n type nat hook prerouting priority -100;\n ip saddr != { 10.80.0.0/24 } tcp dport 8080 dnat ip to 10.80.0.10:80\n }\n chain postrouting {\n type nat hook postrouting priority 100;\n ip saddr { 10.80.0.0/24 } ip daddr != { 10.80.0.0/24 } masquerade\n }\n}\n" },
{ path = "/etc/sysctl.d/90-isoloom.conf", content = "net.ipv4.ip_forward=1\n" },
{ path = "/etc/systemd/network/20-app.network", content = join("\n", ["[Match]", "MACAddress=${format("02:15:%02x:01:%02x:00", var.slot, 0)}", "", "[Network]", "Address=10.80.0.254/24", "ConfigureWithoutCarrier=yes"]) }
]
runcmd = [
["sysctl", "-p", "/etc/sysctl.d/90-isoloom.conf"],
["systemctl", "enable", "systemd-networkd"],
["systemctl", "restart", "systemd-networkd"],
["systemctl", "enable", "--now", "nftables"],
["nft", "-f", "/etc/nftables.conf"],
["systemctl", "enable", "--now", "qemu-guest-agent"],
]
})}"
}
}
resource "proxmox_virtual_environment_vm" "isoloom_router" {
name = "iso${var.slot}-router"
node_name = var.node
tags = ["isoloom", "solo-web"]
on_boot = false
# Its uplink address (DHCP), for the published ports.
agent {
enabled = true
}
cpu {
cores = 1
type = "host"
}
memory {
dedicated = 512
}
disk {
datastore_id = var.datastore
file_id = proxmox_download_file.debian_12.id
interface = "virtio0"
size = 8
}
network_device {
bridge = var.uplink_bridge
mac_address = upper(format("02:15:%02x:00:00:00", var.slot))
}
network_device {
bridge = proxmox_sdn_vnet.app.id
mac_address = upper(format("02:15:%02x:01:%02x:00", var.slot, 0))
}
initialization {
datastore_id = var.datastore
user_data_file_id = proxmox_virtual_environment_file.router.id
ip_config {
ipv4 {
address = "dhcp"
}
}
}
operating_system {
type = "l26"
}
serial_device {}
depends_on = [proxmox_sdn_applier.env]
}
# Machine `web`.
resource "proxmox_virtual_environment_file" "web" {
node_name = var.node
datastore_id = var.snippets_datastore
content_type = "snippets"
source_raw {
file_name = "iso${var.slot}-web.yaml"
data = "#cloud-config\n${yamlencode({
hostname = "web"
users = local.users
packages = ["nftables", "curl", "netcat-openbsd"]
write_files = local.project_files
runcmd = [
["sh", "-c", "cd /opt/isoloom && sh provision/web.sh"],
["sh", "-c", "mkdir -p /var/lib/isoloom && echo ready > /var/lib/isoloom/ready"]
]
})}"
}
}
resource "proxmox_virtual_environment_vm" "web" {
name = "iso${var.slot}-web"
node_name = var.node
tags = ["isoloom", "solo-web"]
on_boot = false
cpu {
cores = 1
type = "host"
}
memory {
dedicated = 1024
}
disk {
datastore_id = var.datastore
file_id = proxmox_download_file.debian_12.id
interface = "virtio0"
size = 20
}
network_device {
bridge = proxmox_sdn_vnet.app.id
}
initialization {
datastore_id = var.datastore
user_data_file_id = proxmox_virtual_environment_file.web.id
dns {
servers = ["1.1.1.1"]
}
ip_config {
ipv4 {
address = "10.80.0.10/24"
gateway = "10.80.0.254"
}
}
}
operating_system {
type = "l26"
}
serial_device {}
depends_on = [proxmox_virtual_environment_vm.isoloom_router]
}
locals {
router_address = [for a in flatten(proxmox_virtual_environment_vm.isoloom_router.ipv4_addresses) : a if a != "127.0.0.1" && !contains(["10.80.0.254"], a)][0]
}
output "address" {
value = local.router_address
}
# Published services, from the router's uplink address.
output "published" {
value = {
"web/80" = "${local.router_address}:8080"
}
}
.isoloom/docker-vm/proxmox/main.tf
# Generated by isoloom from isoloom.yml. Don't edit: change isoloom.yml and run
# `isoloom generate`. `isoloom check` fails when this file is out of date.
# Start: terraform -chdir=.isoloom/docker-vm/proxmox init && terraform -chdir=.isoloom/docker-vm/proxmox apply \
# -var proxmox_endpoint=https://<server>:8006/ -var proxmox_api_token=… -var ssh_public_key="$(cat ~/.ssh/id_ed25519.pub)" -var ssh_private_key_file=~/.ssh/id_ed25519
# Stop: terraform -chdir=.isoloom/docker-vm/proxmox destroy (same variables)
terraform {
required_version = ">= 1.6"
backend "local" {}
required_providers {
proxmox = {
source = "bpg/proxmox"
version = "~> 0.84"
}
}
}
variable "proxmox_endpoint" {
type = string
description = "https://<server>:8006/"
}
variable "proxmox_api_token" {
type = string
default = ""
sensitive = true
description = "user@realm!name=secret; or use proxmox_username and proxmox_password"
}
variable "proxmox_username" {
type = string
default = "root@pam"
}
variable "proxmox_password" {
type = string
default = ""
sensitive = true
}
variable "proxmox_insecure" {
type = bool
default = true
description = "Accept the server's self-signed certificate"
}
variable "proxmox_ssh_username" {
type = string
default = "root"
description = "Uploading the cloud-init snippet goes over SSH to the node"
}
variable "proxmox_ssh_private_key_file" {
type = string
default = ""
}
variable "proxmox_ssh_address" {
type = string
default = ""
description = "The node's SSH address, when the API reports one this machine can't reach"
}
variable "node" {
type = string
default = "pve"
}
variable "datastore" {
type = string
default = "local-lvm"
description = "Where the VM's disk goes"
}
variable "image_datastore" {
type = string
default = "local"
description = "A datastore with 'iso' content, for the cloud image"
}
variable "snippets_datastore" {
type = string
default = "local"
description = "A datastore with 'snippets' content, for cloud-init"
}
variable "uplink_bridge" {
type = string
default = "vmbr0"
description = "The bridge the VM gets its address (DHCP) and the internet from"
}
variable "slot" {
type = number
default = 1
description = "1 to 99, unique per environment on this server"
}
provider "proxmox" {
endpoint = var.proxmox_endpoint
api_token = var.proxmox_api_token != "" ? var.proxmox_api_token : null
username = var.proxmox_api_token != "" ? null : var.proxmox_username
password = var.proxmox_api_token != "" ? null : var.proxmox_password
insecure = var.proxmox_insecure
ssh {
agent = false
username = var.proxmox_ssh_username
password = var.proxmox_ssh_private_key_file != "" ? null : var.proxmox_password
private_key = var.proxmox_ssh_private_key_file != "" ? file(var.proxmox_ssh_private_key_file) : null
dynamic "node" {
for_each = var.proxmox_ssh_address == "" ? [] : [1]
content {
name = var.node
address = var.proxmox_ssh_address
}
}
}
}
locals {
root = abspath("${path.module}/../../..")
# The VM's address on the uplink, from the guest agent (not the loopback).
ip = [for a in flatten(proxmox_virtual_environment_vm.env.ipv4_addresses) : a if a != "127.0.0.1"][0]
}
resource "proxmox_download_file" "debian" {
node_name = var.node
datastore_id = var.image_datastore
content_type = "iso"
url = "https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-genericcloud-amd64.qcow2"
file_name = "iso${var.slot}-docker-debian-12.img"
overwrite_unmanaged = true
}
resource "proxmox_virtual_environment_file" "env" {
node_name = var.node
datastore_id = var.snippets_datastore
content_type = "snippets"
source_raw {
file_name = "iso${var.slot}-docker.yaml"
data = "#cloud-config\n${yamlencode({
hostname = "solo-web"
users = [{
name = "isoloom"
sudo = "ALL=(ALL) NOPASSWD:ALL"
shell = "/bin/bash"
ssh_authorized_keys = [var.ssh_public_key]
}]
packages = ["qemu-guest-agent", "curl"]
runcmd = [["systemctl", "enable", "--now", "qemu-guest-agent"]]
})}"
}
}
resource "proxmox_virtual_environment_vm" "env" {
name = "iso${var.slot}-solo-web"
node_name = var.node
tags = ["isoloom", "solo-web"]
on_boot = false
agent {
enabled = true
}
cpu {
cores = 2
type = "host"
}
memory {
dedicated = 1536
}
disk {
datastore_id = var.datastore
file_id = proxmox_download_file.debian.id
interface = "virtio0"
size = 30
}
network_device {
bridge = var.uplink_bridge
}
initialization {
datastore_id = var.datastore
user_data_file_id = proxmox_virtual_environment_file.env.id
ip_config {
ipv4 {
address = "dhcp"
}
}
}
operating_system {
type = "l26"
}
serial_device {}
}
variable "ssh_public_key" {
type = string
}
variable "ssh_private_key_file" {
type = string
description = "The private key of ssh_public_key: Terraform copies the project over SSH"
}
variable "auto_stop_minutes" {
type = number
default = 0
description = "Shut the VM down after this many minutes (0: never). Destroy still ends the billing of disks and addresses"
}
# The environment, over SSH: the project, Docker, then the Compose file.
resource "terraform_data" "environment" {
triggers_replace = [proxmox_virtual_environment_vm.env.id]
connection {
type = "ssh"
host = local.ip
user = "isoloom"
private_key = file(pathexpand(var.ssh_private_key_file))
timeout = "10m"
}
provisioner "remote-exec" {
inline = [
"cloud-init status --wait >/dev/null 2>&1 || true",
var.auto_stop_minutes > 0 ? "sudo shutdown -h +${var.auto_stop_minutes} >/dev/null 2>&1" : "true",
"sudo mkdir -p /opt/isoloom && sudo chown isoloom /opt/isoloom",
]
}
# The project as an archive: a plain copy drops the executable bits (entrypoint scripts).
provisioner "local-exec" {
command = "tar -czf \"${path.module}/.isoloom-project.tgz\" --exclude=.git --exclude=.vagrant --exclude=.terraform --exclude=.isoloom-project.tgz -C \"${local.root}\" ."
}
provisioner "file" {
source = "${path.module}/.isoloom-project.tgz"
destination = "/tmp/isoloom-project.tgz"
}
provisioner "remote-exec" {
inline = [
"set -e",
"tar -xzf /tmp/isoloom-project.tgz -C /opt/isoloom && rm -f /tmp/isoloom-project.tgz",
"command -v docker >/dev/null || curl -fsSL https://get.docker.com | sudo sh",
"cd /opt/isoloom && sudo -E env ISOLOOM_PUBLISH_ADDRESS=0.0.0.0 ISOLOOM_PUBLISH_FIXED=1 docker compose -f .isoloom/docker/compose.yml up -d --build --wait --wait-timeout 900",
"sudo mkdir -p /var/lib/isoloom && echo ready | sudo tee /var/lib/isoloom/ready >/dev/null",
]
}
}
output "ip" {
value = local.ip
}
output "ssh_user" {
value = "isoloom"
}
output "ready_file" {
value = "/var/lib/isoloom/ready"
}
.isoloom/cloud-docker/aws/main.tf
# Generated by isoloom from isoloom.yml. Don't edit: change isoloom.yml and run
# `isoloom generate`. `isoloom check` fails when this file is out of date.
# Start: terraform -chdir=.isoloom/cloud-docker/aws init && terraform -chdir=.isoloom/cloud-docker/aws apply \
# -var allowed_cidr=<your IP>/32 -var ssh_public_key="$(cat ~/.ssh/id_ed25519.pub)" -var ssh_private_key_file=~/.ssh/id_ed25519
# Stop: terraform -chdir=.isoloom/cloud-docker/aws destroy (same variables)
terraform {
required_version = ">= 1.6"
backend "local" {}
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
}
}
variable "region" {
type = string
default = "eu-west-3"
}
variable "allowed_cidr" {
type = string
description = "Who may reach the VM (SSH and the published ports), e.g. your IP/32"
}
variable "ssh_public_key" {
type = string
}
variable "ssh_private_key_file" {
type = string
description = "The private key of ssh_public_key: Terraform copies the project over SSH"
}
variable "instance_type" {
type = string
default = "t3.small"
}
variable "auto_stop_minutes" {
type = number
default = 0
description = "Shut the VM down (and terminate it) after this long; 0 = never"
}
provider "aws" {
region = var.region
default_tags {
tags = {
"isoloom:environment" = "solo-web"
"managed-by" = "isoloom"
}
}
}
# A suffix, so two copies of the environment in one account don't collide.
resource "terraform_data" "id" {
input = substr(replace(uuid(), "-", ""), 0, 8)
lifecycle {
ignore_changes = [input]
}
}
locals {
name = "isoloom-solo-web-${terraform_data.id.output}"
root = abspath("${path.module}/../../..")
}
data "aws_availability_zones" "available" {
state = "available"
}
data "aws_ami" "debian" {
most_recent = true
owners = ["136693071363"]
filter {
name = "name"
values = ["debian-12-amd64-*"]
}
}
resource "aws_vpc" "env" {
cidr_block = "10.42.0.0/16"
tags = { Name = local.name }
}
resource "aws_internet_gateway" "env" {
vpc_id = aws_vpc.env.id
tags = { Name = local.name }
}
resource "aws_subnet" "env" {
vpc_id = aws_vpc.env.id
cidr_block = "10.42.1.0/24"
availability_zone = data.aws_availability_zones.available.names[0]
map_public_ip_on_launch = true
tags = { Name = local.name }
}
resource "aws_route_table" "env" {
vpc_id = aws_vpc.env.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.env.id
}
tags = { Name = local.name }
}
resource "aws_route_table_association" "env" {
subnet_id = aws_subnet.env.id
route_table_id = aws_route_table.env.id
}
resource "aws_key_pair" "env" {
key_name = local.name
public_key = var.ssh_public_key
}
resource "aws_security_group" "env" {
name = local.name
vpc_id = aws_vpc.env.id
ingress {
description = "SSH"
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = [var.allowed_cidr]
}
ingress {
description = "published 8080"
from_port = 8080
to_port = 8080
protocol = "tcp"
cidr_blocks = [var.allowed_cidr]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
resource "aws_instance" "env" {
ami = data.aws_ami.debian.id
instance_type = var.instance_type
subnet_id = aws_subnet.env.id
key_name = aws_key_pair.env.key_name
vpc_security_group_ids = [aws_security_group.env.id]
instance_initiated_shutdown_behavior = "terminate"
user_data = var.auto_stop_minutes > 0 ? "#!/bin/sh\nshutdown -h +${var.auto_stop_minutes}\n" : null
root_block_device {
volume_size = 20
volume_type = "gp3"
encrypted = true
}
metadata_options {
http_tokens = "required"
}
depends_on = [aws_route_table_association.env]
tags = { Name = local.name }
}
# The environment, over SSH: the project, Docker, then the Compose file.
resource "terraform_data" "environment" {
triggers_replace = [aws_instance.env.id]
connection {
type = "ssh"
host = aws_instance.env.public_ip
user = "admin"
private_key = file(pathexpand(var.ssh_private_key_file))
timeout = "10m"
}
provisioner "remote-exec" {
inline = ["cloud-init status --wait >/dev/null 2>&1 || true", "sudo mkdir -p /opt/isoloom && sudo chown admin /opt/isoloom"]
}
# The project as an archive: a plain copy drops the executable bits (entrypoint scripts).
provisioner "local-exec" {
command = "tar -czf \"${path.module}/.isoloom-project.tgz\" --exclude=.git --exclude=.vagrant --exclude=.terraform --exclude=.isoloom-project.tgz -C \"${local.root}\" ."
}
provisioner "file" {
source = "${path.module}/.isoloom-project.tgz"
destination = "/tmp/isoloom-project.tgz"
}
provisioner "remote-exec" {
inline = [
"set -e",
"tar -xzf /tmp/isoloom-project.tgz -C /opt/isoloom && rm -f /tmp/isoloom-project.tgz",
"command -v docker >/dev/null || curl -fsSL https://get.docker.com | sudo sh",
"cd /opt/isoloom && sudo -E env ISOLOOM_PUBLISH_ADDRESS=0.0.0.0 ISOLOOM_PUBLISH_FIXED=1 docker compose -f .isoloom/docker/compose.yml up -d --build --wait --wait-timeout 900",
"sudo mkdir -p /var/lib/isoloom && echo ready | sudo tee /var/lib/isoloom/ready >/dev/null",
]
}
}
output "ip" {
value = aws_instance.env.public_ip
}
output "ssh_user" {
value = "admin"
}
output "ready_file" {
value = "/var/lib/isoloom/ready"
}
The other clouds, the same shape as AWS: azure, gcp, digitalocean, linode, oci.