Cloud Documentation

RA-02. Single-Tier VPC - Web, App and Database

Introduction

A classic three-server web application in a VPC: a web server, an application server and a database, all sharing one subnet behind a network ACL. This is the smallest deployment that gives you private addressing, a VPC router and room to grow, and it's the natural next step once one server is no longer enough.

What you deploy

RA-02 architecture: internet traffic reaches a static NAT public IP in the au-mel01 zone, passes the VPC router where the network ACL is enforced, and arrives at web01 in the prod-app tier. web01, app01 and db01 all sit in the same 10.1.1.0/24 subnet and talk to each other directly without passing the router. db01 has a 100 GB data volume attached. A second public IP is held by the VPC for source NAT egress.
RA-02 architecture - view full size. Built with the Lightspeed Cloud icon set.
  • One VPC (prod, 10.1.0.0/16) on vpc-std-v1 with a redundant VPC router.
  • One network tier (prod-app, 10.1.1.0/24) on net-vpctier-std-v1, governed by a custom network ACL.
  • Three VMs - web01, app01 and db01 - with static guest IPs.
  • One 100 GB data volume on db01 for the database.
  • A public IP published to web01 by static NAT.

This suits an application whose components you want on private addresses, reachable from the internet only through the web tier. If you need the network itself to stop the web server talking to the database, you want separate tiers instead - that's RA-03. If one server is enough, RA-01 is simpler and cheaper.

Architecture

Traffic flow

Users reach the static NAT public IP. The VPC router applies the tier's ingress ACL and forwards traffic to web01. web01 calls app01, and app01 queries db01, both over the tier's own subnet.

That last sentence is the important one. Traffic between VMs in the same tier never reaches the VPC router, so the network ACL never sees it and can't filter it. The ACL is a boundary control: it applies to traffic entering or leaving the tier, not to traffic within it. See What a single tier isolates below.

The VPC also holds a source NAT public IP, allocated automatically when the VPC is created. Outbound traffic from app01 and db01 leaves via that IP; outbound from web01 leaves via its static NAT IP.

Resource plan

ResourceValueNotes
Zoneau-mel01
VPC offeringvpc-std-v1Redundant VPC router, NAT, ACLs, VPN, load balancing
VPC CIDR10.1.0.0/16Sized for growth. Only 10.1.1.0/24 is used here
Tier offeringnet-vpctier-std-v1Public load balancer capable, network ACL enforced at the tier boundary
Tier subnet10.1.1.0/24, gateway 10.1.1.1
web01s-small-gen2, 10.1.1.112 vCPU / 4 GB. nginx or Apache
app01s-medium-gen2, 10.1.1.122 vCPU / 8 GB. Application runtime
db01m-small-gen2, 10.1.1.132 vCPU / 16 GB. Memory-optimised for the database working set
Root disks40 GB each
Data volume100 GB on db01, mounted at /var/lib/mysqlSurvives VM rebuilds, snapshots independently
Public IPs2One source NAT IP held by the VPC automatically, one static NAT IP for web01

Check these against your account's resource limits before you start.

Why the VPC is a /16 when you only need a /24

The VPC CIDR is fixed at creation and every tier must sit inside it. A /16 costs nothing, and it means 10.1.2.0/24 and 10.1.3.0/24 are still available when you decide to split this application into separate web, app and database tiers. Growing into RA-03 later then becomes a migration of VMs between tiers rather than a rebuild of the whole VPC.

What a single tier isolates

This is the design trade-off of RA-02, and it's worth being blunt about.

TrafficControlled by the ACL?
Internet to web01Yes - ingress rules
web01, app01 or db01 to the internetYes - egress rules
Another VPC tier to this tierYes - ingress rules
web01 to db01No
app01 to db01No

Network ACLs are applied by the VPC router at the tier boundary. Two VMs in 10.1.1.0/24 talk to each other directly over the guest network without their packets ever passing through the router, so there's no point at which an ACL could act on them. Writing a deny rule for 10.1.1.11 to 10.1.1.13 will not do anything.

CloudStack security groups, which do filter VM to VM traffic, aren't available on VPC tiers on this platform.

So in a single-tier VPC, isolation between roles is a host responsibility. Configure a host firewall on each VM, as covered in Host firewall configuration. If you'd rather the network enforced it, that's RA-03: the same three servers in three tiers, where every cross-role packet has to traverse the router and can be filtered.

A single tier is still a meaningful security boundary. app01 and db01 have no public IP, no static NAT and no port forwarding, so they're unreachable from the internet regardless of the host firewall. The gap is lateral movement: if web01 is compromised, nothing in the network stops it reaching the database port.

Network ACL plan

The tier uses a custom ACL, prod-app-acl. Rules are numbered with gaps of ten so you can insert without renumbering. Anything not matched is denied.

Rule numbers are unique across the whole ACL list, not per direction. Ingress and egress rules share one number space, so an egress rule numbered 100 collides with an ingress rule numbered 100 and the platform rejects it with ACL item with number 100 already exists. That's why ingress uses the 100 band below and egress uses the 200 band. It's a convention, not a platform rule - any non-overlapping split works - but keeping the two directions in separate bands makes the collision impossible to hit as the rule set grows.

Ingress

NumberActionProtocolPortsSource
100AllowTCP800.0.0.0/0
110AllowTCP4430.0.0.0/0
120AllowTCP22Admin CIDR

Egress

NumberActionProtocolPortsDestination
200AllowTCP800.0.0.0/0
210AllowTCP4430.0.0.0/0
220AllowUDP530.0.0.0/0
230AllowTCP530.0.0.0/0
240AllowUDP1230.0.0.0/0

Egress is deny-by-default here, which is the right posture but does mean anything the servers need outbound has to be listed. The rules above cover package updates over HTTP and HTTPS, DNS and NTP. Add SMTP submission on 587 if the application sends mail, and note that a database replicating off-platform or a backup agent shipping to object storage both need their own rules.

Two differences from RA-01 worth internalising:

  • In a VPC there's no firewall tab on public IPs. Ingress control is the tier ACL and nothing else. Static NAT and port forwarding decide where traffic goes; the ACL decides whether it's allowed.
  • Return traffic is permitted automatically. The VPC router tracks connections, so an allowed outbound request gets its reply back without a matching ingress rule. You don't write mirrored rules.

Publishing web01

web01 is published with static NAT on a dedicated public IP, exactly as in RA-01. The VPC already holds a source NAT IP for egress, so this is a second public IP.

The single-IP alternative is port forwarding 80 and 443 from the VPC's source NAT IP to web01. It saves an IP; it also means web01 shares its outbound identity with app01 and db01, which matters if the application sends mail or calls an API that allowlists source IPs.

Whichever you choose, the ACL rules above still have to be in place. Publishing without them gives you a VM that resolves and routes but never answers.

Portal walkthrough

1. Register your SSH key

Compute → SSH key pairs → Create SSH key pair. Name it ops-key and paste your public key. See SSH keypairs for details.

2. Create the VPC

Network → VPC → Add VPC. Name prod, zone au-mel01, super CIDR 10.1.0.0/16, offering vpc-std-v1. Leave the network domain blank to inherit the default.

The VPC router is created and a source NAT public IP is allocated automatically.

3. Create the network ACL

Network → VPC → prod → Network ACL lists → Add ACL list. Name it prod-app-acl.

Open the new list and add each rule from the network ACL plan. The dialog asks for rule number, CIDR, action, protocol, start port, end port and traffic type.

Create the ACL before the tier. A tier can be re-pointed at a different ACL later, but starting with the right one avoids a window where the tier is running under default_allow.

4. Create the tier

Network → VPC → prod → Add network tier. Name prod-app, offering net-vpctier-std-v1, gateway 10.1.1.1, netmask 255.255.255.0, ACL prod-app-acl.

Unlike an isolated network, a VPC tier needs its gateway and netmask stated explicitly. They must fall inside the VPC's super CIDR.

5. Deploy the three VMs

Compute → Instances → Add instance, three times. Template almalinux-9, network prod-app, SSH key ops-key, root disk 40 GB, and:

NameCompute offeringIP address
web01s-small-gen210.1.1.11
app01s-medium-gen210.1.1.12
db01m-small-gen210.1.1.13

Set the IP explicitly in the network section of the dialog. Left blank, CloudStack allocates from the bottom of the range and you lose the predictable addressing the host firewall rules depend on.

6. Add the database volume

Storage → Volumes → Create volume. Name it db01-data, 100 GB, then Attach it to db01.

7. Publish web01

Network → VPC → prod → Public IP addresses → Acquire new IP. On the new IP, select Static NAT → Enable, choose web01, and select the prod-app tier when prompted.

In a VPC you must tell the platform which tier the target VM sits in. This is the step people miss when moving from isolated networks to VPCs.

Host firewall configuration

Because the ACL can't filter within the tier, each VM carries its own rules. These examples use firewalld on AlmaLinux 9. Run them as root on each VM.

web01 - public facing, so this mostly duplicates the ACL as defence in depth:

firewall-cmd --permanent --add-service=http
firewall-cmd --permanent --add-service=https
firewall-cmd --permanent --add-rich-rule='rule family=ipv4 source address=1.2.3.4/32 service name=ssh accept'
firewall-cmd --permanent --remove-service=ssh
firewall-cmd --reload

app01 - accepts the application port from web01 only:

firewall-cmd --permanent --add-rich-rule='rule family=ipv4 source address=10.1.1.11/32 port port=8080 protocol=tcp accept'
firewall-cmd --permanent --add-rich-rule='rule family=ipv4 source address=1.2.3.4/32 service name=ssh accept'
firewall-cmd --permanent --remove-service=ssh
firewall-cmd --reload

db01 - accepts MySQL from app01 only, and specifically not from web01:

firewall-cmd --permanent --add-rich-rule='rule family=ipv4 source address=10.1.1.12/32 port port=3306 protocol=tcp accept'
firewall-cmd --permanent --add-rich-rule='rule family=ipv4 source address=1.2.3.4/32 service name=ssh accept'
firewall-cmd --permanent --remove-service=ssh
firewall-cmd --reload

Also prepare the database volume on db01 before installing MySQL or MariaDB:

mkfs.xfs /dev/vdb
mkdir -p /var/lib/mysql
echo '/dev/vdb /var/lib/mysql xfs defaults 0 0' >> /etc/fstab
mount -a
These host rules are the only thing separating the roles. Treat them as part of the architecture, not as server hygiene: put them in configuration management and check them in the same review that covers the ACL.

Deploy with CloudMonkey

CloudMonkey is the CloudStack CLI. Configure it with the API key and secret key from your account settings, pointed at https://cloud.lightspeedhosting.com.au/client/api. These commands mirror the portal walkthrough step for step.

# 1. Register your SSH key
cmk register sshkeypair name=ops-key publickey="$(cat ~/.ssh/id_ed25519.pub)"

# 2. Create the VPC
ZONE=$(cmk list zones name=au-mel01 filter=id | jq -r '.zone[0].id')
VPCOFF=$(cmk list vpcofferings name=vpc-std-v1 filter=id | jq -r '.vpcoffering[0].id')
VPC=$(cmk create vpc name=prod displaytext=prod zoneid=$ZONE \
  cidr=10.1.0.0/16 vpcofferingid=$VPCOFF | jq -r '.vpc.id')

3. Create the network ACL and its rules

ADMIN=1.2.3.4/32   # your admin CIDR
ACL=$(cmk create networkacllist name=prod-app-acl \
  description="ACL for the prod-app tier" vpcid=$VPC | jq -r '.networkacllist.id')

# Ingress - the 100 band
cmk create networkacl aclid=$ACL number=100 action=allow protocol=tcp \
  startport=80 endport=80 cidrlist=0.0.0.0/0 traffictype=ingress
cmk create networkacl aclid=$ACL number=110 action=allow protocol=tcp \
  startport=443 endport=443 cidrlist=0.0.0.0/0 traffictype=ingress
cmk create networkacl aclid=$ACL number=120 action=allow protocol=tcp \
  startport=22 endport=22 cidrlist=$ADMIN traffictype=ingress

# Egress - the 200 band. Numbers are unique across the whole list,
# so these cannot reuse 100/110/120.
cmk create networkacl aclid=$ACL number=200 action=allow protocol=tcp \
  startport=80 endport=80 cidrlist=0.0.0.0/0 traffictype=egress
cmk create networkacl aclid=$ACL number=210 action=allow protocol=tcp \
  startport=443 endport=443 cidrlist=0.0.0.0/0 traffictype=egress
cmk create networkacl aclid=$ACL number=220 action=allow protocol=udp \
  startport=53 endport=53 cidrlist=0.0.0.0/0 traffictype=egress
cmk create networkacl aclid=$ACL number=230 action=allow protocol=tcp \
  startport=53 endport=53 cidrlist=0.0.0.0/0 traffictype=egress
cmk create networkacl aclid=$ACL number=240 action=allow protocol=udp \
  startport=123 endport=123 cidrlist=0.0.0.0/0 traffictype=egress
Omitting number is allowed - the platform assigns the highest number currently in the list plus one. That's fine for a first build, but it means the numbering depends on the order the calls happen to land in, so state the numbers explicitly if you want the rule set to match the plan above.

4. Create the tier

TIEROFF=$(cmk list networkofferings name=net-vpctier-std-v1 filter=id | jq -r '.networkoffering[0].id')
TIER=$(cmk create network name=prod-app displaytext=prod-app zoneid=$ZONE \
  networkofferingid=$TIEROFF vpcid=$VPC gateway=10.1.1.1 netmask=255.255.255.0 \
  aclid=$ACL | jq -r '.network.id')

5. Deploy the three VMs

TMPL=$(cmk list templates templatefilter=executable keyword="almalinux-9" filter=id | jq -r '.template[0].id')

deploy_vm () {   # name  offering  ip
  OFF=$(cmk list serviceofferings name=$2 filter=id | jq -r '.serviceoffering[0].id')
  cmk deploy virtualmachine name=$1 zoneid=$ZONE templateid=$TMPL \
    serviceofferingid=$OFF networkids=$TIER ipaddress=$3 \
    keypair=ops-key rootdisksize=40
}

deploy_vm web01 s-small-gen2  10.1.1.11
deploy_vm app01 s-medium-gen2 10.1.1.12
deploy_vm db01  m-small-gen2  10.1.1.13

6. Create and attach the database volume

DISKOFF=$(cmk list diskofferings name="Custom" filter=id | jq -r '.diskoffering[0].id')
DB=$(cmk list virtualmachines name=db01 filter=id | jq -r '.virtualmachine[0].id')
VOL=$(cmk create volume name=db01-data zoneid=$ZONE diskofferingid=$DISKOFF size=100 | jq -r '.volume.id')
cmk attach volume id=$VOL virtualmachineid=$DB

7. Publish web01 with static NAT

WEB=$(cmk list virtualmachines name=web01 filter=id | jq -r '.virtualmachine[0].id')
IP=$(cmk associate ipaddress vpcid=$VPC | jq -r '.ipaddress.id')
cmk enable staticnat ipaddressid=$IP virtualmachineid=$WEB networkid=$TIER
enable staticnat needs networkid when the public IP belongs to a VPC. Without it the call fails, because the platform can't infer which tier the target VM is on.

Deploy with Ansible

Same requirements as RA-01:

ansible-galaxy collection install ngine_io.cloudstack
pip install cs sshpubkeys

export CLOUDSTACK_ENDPOINT=https://cloud.lightspeedhosting.com.au/client/api
export CLOUDSTACK_KEY=<api key>
export CLOUDSTACK_SECRET=<secret key>
---
# ra02-single-tier-vpc.yml - deploy RA-02 single-tier VPC
- name: RA-02 single-tier VPC with web, app and database
  hosts: localhost
  connection: local
  gather_facts: false
  vars:
    zone: au-mel01
    admin_cidr: 1.2.3.4/32          # your admin network
    template: almalinux-9
    vpc_cidr: 10.1.0.0/16
    tier_gateway: 10.1.1.1
    tier_netmask: 255.255.255.0

    instances:
      - {name: web01, offering: s-small-gen2,  ip: 10.1.1.11}
      - {name: app01, offering: s-medium-gen2, ip: 10.1.1.12}
      - {name: db01,  offering: m-small-gen2,  ip: 10.1.1.13}

    # rule_position maps to the CloudStack rule number, which is unique
    # across the whole ACL list. Ingress takes the 100 band, egress the 200
    # band - see the note below.
    acl_ingress:
      - {n: 100, protocol: tcp, s: 80,  e: 80,  cidr: 0.0.0.0/0}
      - {n: 110, protocol: tcp, s: 443, e: 443, cidr: 0.0.0.0/0}
      - {n: 120, protocol: tcp, s: 22,  e: 22,  cidr: "{{ admin_cidr }}"}

    acl_egress:
      - {n: 200, protocol: tcp, s: 80,  e: 80,  cidr: 0.0.0.0/0}
      - {n: 210, protocol: tcp, s: 443, e: 443, cidr: 0.0.0.0/0}
      - {n: 220, protocol: udp, s: 53,  e: 53,  cidr: 0.0.0.0/0}
      - {n: 230, protocol: tcp, s: 53,  e: 53,  cidr: 0.0.0.0/0}
      - {n: 240, protocol: udp, s: 123, e: 123, cidr: 0.0.0.0/0}

  tasks:
    - name: Register SSH key
      ngine_io.cloudstack.cs_sshkeypair:
        name: ops-key
        public_key: "{{ lookup('file', '~/.ssh/id_ed25519.pub') }}"

    - name: Create VPC
      ngine_io.cloudstack.cs_vpc:
        name: prod
        zone: "{{ zone }}"
        cidr: "{{ vpc_cidr }}"
        vpc_offering: vpc-std-v1

    - name: Create network ACL
      ngine_io.cloudstack.cs_network_acl:
        name: prod-app-acl
        description: ACL for the prod-app tier
        vpc: prod
        zone: "{{ zone }}"

    - name: Ingress ACL rules
      ngine_io.cloudstack.cs_network_acl_rule:
        network_acl: prod-app-acl
        vpc: prod
        zone: "{{ zone }}"
        rule_position: "{{ item.n }}"
        action_policy: allow
        traffic_type: ingress
        protocol: "{{ item.protocol }}"
        start_port: "{{ item.s }}"
        end_port: "{{ item.e }}"
        cidrs: ["{{ item.cidr }}"]
      loop: "{{ acl_ingress }}"

    - name: Egress ACL rules
      ngine_io.cloudstack.cs_network_acl_rule:
        network_acl: prod-app-acl
        vpc: prod
        zone: "{{ zone }}"
        rule_position: "{{ item.n }}"
        action_policy: allow
        traffic_type: egress
        protocol: "{{ item.protocol }}"
        start_port: "{{ item.s }}"
        end_port: "{{ item.e }}"
        cidrs: ["{{ item.cidr }}"]
      loop: "{{ acl_egress }}"

    - name: Create the VPC tier
      ngine_io.cloudstack.cs_network:
        name: prod-app
        zone: "{{ zone }}"
        vpc: prod
        acl: prod-app-acl
        network_offering: net-vpctier-std-v1
        gateway: "{{ tier_gateway }}"
        netmask: "{{ tier_netmask }}"

    - name: Deploy instances
      ngine_io.cloudstack.cs_instance:
        name: "{{ item.name }}"
        zone: "{{ zone }}"
        template: "{{ template }}"
        service_offering: "{{ item.offering }}"
        networks: [prod-app]
        ip_address: "{{ item.ip }}"
        ssh_key: ops-key
        root_disk_size: 40
      loop: "{{ instances }}"

    - name: Create and attach the database volume
      ngine_io.cloudstack.cs_volume:
        name: db01-data
        zone: "{{ zone }}"
        disk_offering: Custom
        size: 100
        vm: db01
        state: attached

    # The tag is what makes this idempotent. Without it, cs_ip_address cannot
    # find the IP it acquired last time and acquires another one on every run.
    # Use exactly one tag: the module's matching logic sorts the tag list and
    # raises a TypeError on two or more.
    - name: Acquire public IP on the VPC
      ngine_io.cloudstack.cs_ip_address:
        zone: "{{ zone }}"
        vpc: prod
        tags:
          - key: purpose
            value: web01-staticnat
      register: pub

    - name: Enable static NAT to web01
      ngine_io.cloudstack.cs_staticnat:
        ip_address: "{{ pub.ip_address }}"
        vm: web01
        vpc: prod
        network: prod-app
        zone: "{{ zone }}"

    - name: Show public IP
      ansible.builtin.debug:
        msg: "web01 is reachable at {{ pub.ip_address }}"
rule_position is the ACL rule number, and it's the module's entire idea of rule identity. It matches an existing rule on the number alone - it does not also compare the traffic type. So reusing a number across the two directions doesn't produce a duplicate error from the module; it finds the ingress rule and rewrites it into an egress rule, and you silently end up with fewer rules than you wrote. Keeping ingress and egress in separate number bands, as above, avoids that entirely. Reusing a number with different contents within one direction updates that rule in place, which is what makes re-runs safe.

The host firewall configuration is a separate concern. Run it as a second play against the three VMs using ansible.posix.firewalld, or bake it into the template.

Deploy with Terraform

The CloudStack provider changed the shape of ACL rules in 0.6.0, so this configuration pins ~> 0.6.0 rather than a looser constraint. See Provider notes below before you widen it.

# ra02-single-tier-vpc.tf - deploy RA-02 single-tier VPC
terraform {
  required_providers {
    cloudstack = {
      source  = "cloudstack/cloudstack"
      version = "~> 0.6.0"
    }
  }
}

variable "api_url"    { default = "https://cloud.lightspeedhosting.com.au/client/api" }
variable "api_key"    { sensitive = true }
variable "secret_key" { sensitive = true }
variable "admin_cidr" { default = "1.2.3.4/32" }

provider "cloudstack" {
  api_url    = var.api_url
  api_key    = var.api_key
  secret_key = var.secret_key
}

locals {
  zone = "au-mel01"

  instances = {
    web01 = { offering = "s-small-gen2",  ip = "10.1.1.11" }
    app01 = { offering = "s-medium-gen2", ip = "10.1.1.12" }
    db01  = { offering = "m-small-gen2",  ip = "10.1.1.13" }
  }

  # One entry per rule, mirroring the ACL plan exactly.
  acl_rules = [
    { number = 100, protocol = "tcp", port = "80",  cidr = "0.0.0.0/0",     traffic = "ingress" },
    { number = 110, protocol = "tcp", port = "443", cidr = "0.0.0.0/0",     traffic = "ingress" },
    { number = 120, protocol = "tcp", port = "22",  cidr = var.admin_cidr,  traffic = "ingress" },
    { number = 200, protocol = "tcp", port = "80",  cidr = "0.0.0.0/0",     traffic = "egress" },
    { number = 210, protocol = "tcp", port = "443", cidr = "0.0.0.0/0",     traffic = "egress" },
    { number = 220, protocol = "udp", port = "53",  cidr = "0.0.0.0/0",     traffic = "egress" },
    { number = 230, protocol = "tcp", port = "53",  cidr = "0.0.0.0/0",     traffic = "egress" },
    { number = 240, protocol = "udp", port = "123", cidr = "0.0.0.0/0",     traffic = "egress" },
  ]
}

resource "cloudstack_ssh_keypair" "ops" {
  name       = "ops-key"
  public_key = file("~/.ssh/id_ed25519.pub")
}

resource "cloudstack_vpc" "prod" {
  name         = "prod"
  display_text = "prod"
  cidr         = "10.1.0.0/16"
  vpc_offering = "vpc-std-v1"
  zone         = local.zone
}

resource "cloudstack_network_acl" "prod_app" {
  name        = "prod-app-acl"
  description = "ACL for the prod-app tier"
  vpc_id      = cloudstack_vpc.prod.id
}

resource "cloudstack_network_acl_rule" "prod_app" {
  acl_id = cloudstack_network_acl.prod_app.id

  dynamic "rule" {
    for_each = local.acl_rules
    content {
      rule_number  = rule.value.number
      action       = "allow"
      cidr_list    = [rule.value.cidr]
      protocol     = rule.value.protocol
      port         = rule.value.port
      traffic_type = rule.value.traffic
    }
  }
}

resource "cloudstack_network" "prod_app" {
  name             = "prod-app"
  cidr             = "10.1.1.0/24"
  network_offering = "net-vpctier-std-v1"
  vpc_id           = cloudstack_vpc.prod.id
  acl_id           = cloudstack_network_acl.prod_app.id
  zone             = local.zone
}

resource "cloudstack_instance" "vm" {
  for_each = local.instances

  name             = each.key
  zone             = local.zone
  template         = "almalinux-9"
  service_offering = each.value.offering
  network_id       = cloudstack_network.prod_app.id
  ip_address       = each.value.ip
  keypair          = cloudstack_ssh_keypair.ops.name
  root_disk_size   = 40
  expunge          = true
}

resource "cloudstack_disk" "db_data" {
  name               = "db01-data"
  zone               = local.zone
  disk_offering      = "Custom"
  size               = 100
  attach             = true
  virtual_machine_id = cloudstack_instance.vm["db01"].id
}

resource "cloudstack_ipaddress" "web" {
  zone   = local.zone
  vpc_id = cloudstack_vpc.prod.id
}

resource "cloudstack_static_nat" "web01" {
  ip_address_id      = cloudstack_ipaddress.web.id
  virtual_machine_id = cloudstack_instance.vm["web01"].id
}

output "web_public_ip" {
  value = cloudstack_ipaddress.web.ip_address
}
terraform init
terraform validate
terraform apply -var api_key=... -var secret_key=...

Provider notes

Three behaviours of the CloudStack provider are worth knowing before you adapt this.

The tier is resolved from the VM, so cloudstack_static_nat needs no tier argument. Where CloudMonkey makes you pass networkid, the provider looks the VM up and enables static NAT against the network of its first NIC. For the three single-NIC VMs here that is always prod-app, and the resource works on a VPC public IP as written.

The caveat is that it's the first NIC rather than a chosen one. If you later give a VM a second NIC in another tier, add vm_guest_ip to pin which address gets published:

resource "cloudstack_static_nat" "web01" {
  ip_address_id      = cloudstack_ipaddress.web.id
  virtual_machine_id = cloudstack_instance.vm["web01"].id
  vm_guest_ip        = "10.1.1.11"
}

The provider matches that address against the VM's NICs, including secondary IPs, and uses the matching NIC's network. It's harmless on a single-NIC VM and it makes the intent explicit, so it's reasonable to set it from the start.

Rule numbers are yours to choose, from 0.6.0 onward. Each rule block takes an optional rule_number (1-65535), which is passed straight through as the CloudStack rule number - so the Terraform rule set can match the ACL plan number for number, exactly like the CloudMonkey and Ansible versions. Leave it out and it becomes a computed value: the platform assigns the next number, and because the provider creates rules concurrently (two at a time by default, tunable with parallelism), the numbering won't reliably follow the order you wrote them in. State the numbers.

One port per rule block. 0.6.0 replaced the old ports list with a single port string, accepting either "80" or a range like "8000-8010". The old field still appears in the schema but is rejected at create time with "The 'ports' field is no longer supported", so older examples written as ports = ["80", "443"] will plan cleanly and then fail on apply. Write one block per port, as the dynamic block above does.

That last point is why the version constraint is ~> 0.6.0 and not ~> 0.5. A two-part constraint like ~> 0.5 allows any 0.x from 0.5.0 up, so it silently resolves to 0.6.0 and a configuration written for 0.5 fails on apply. Pin the minor while the provider is pre-1.0.

Verify

  1. curl -I http://<public-ip> reaches web01.
  2. SSH to web01 works from your admin CIDR and times out from anywhere else.
  3. From web01, curl -I https://almalinux.org succeeds (egress 443 allowed) and nc -vz 1.1.1.1 4444 fails (egress denied by default).
  4. From app01, mysql -h 10.1.1.13 -u app -p connects.
  5. The intra-tier test. From web01, run nc -vz 10.1.1.13 3306. Before you apply the host firewall on db01, this succeeds even though no ACL rule permits it - which demonstrates that the ACL doesn't filter within the tier. After you apply the host firewall, it fails. That before-and-after is worth doing once so the boundary is understood rather than assumed.
  6. df -h /var/lib/mysql on db01 shows the 100 GB volume.
  7. The ACL list shows eight rules - three ingress and five egress. If it shows fewer, a rule number was reused across the two directions; see the network ACL plan.

Day 2 operations

  • Adding a tier later. Create it inside the same VPC on an unused /24 such as 10.1.2.0/24, give it its own ACL, and move VMs across. The VPC, its public IPs and its VPN keep working throughout. This is the path from RA-02 to RA-03.
  • Changing ACL rules. Rules take effect on the VPC router immediately - no VM restart is needed. Adding or removing a tier does restart the router, which briefly interrupts NAT and VPN.
  • Snapshots. Schedule recurring snapshots on db01's data volume. Snapshot the root volumes before OS upgrades.
  • Backups. A volume snapshot isn't a database backup. Run a logical dump on db01 to object storage as well.
  • Scaling up. Stop the VM and change its compute offering. db01 is the usual first candidate - m-medium-gen2 gives 4 vCPU and 32 GB. See resizing a VM.
  • Scaling out. Two web servers behind a public load balancer, and an internal load balancer in front of the application servers, is RA-04. It also needs net-vpctier-intlb-v1 for the internal LB tier, which a single tier can't provide alongside the public LB offering.
  • Watch the host firewall rules. They encode the VM IP addresses. If you rebuild a VM and it comes back on a different address, the rules silently stop matching. Pin the IPs, as in step 5.