Cloud Documentation

RA-01. Basic VM - Single cPanel Server

The simplest Lightspeed Cloud deployment: one VM on an isolated network with a public IP. The example workload is a cPanel/WHM web hosting server, but the pattern applies to any single-server workload - a game server, a small ERP, a build agent.

What you deploy

  • One isolated network (net-isolated-std-v1) with a redundant virtual router.
  • One VM (cpanel01) running AlmaLinux 9 on s-medium-gen2 - 2 vCPU / 8 GB.
  • One 100 GB data volume mounted at /home.
  • A public IP published to the VM by static NAT (or port forwarding), with firewall rules for web, mail and control panel traffic.

This suits a single server that needs its own identity on the internet. If you're running more than one server behind shared routing, or you need private tiers that aren't internet-reachable, start with a VPC instead.

Architecture

RA-01 architecture: internet traffic reaches a static NAT public IP in the au-mel01 zone, passes firewall rules and the redundant virtual router, and arrives at the cpanel01 instance on the prod isolated network, which has a 100 GB data volume attached. A second public IP is held by the network for source NAT egress.
RA-01 architecture - view full size. Built with the Lightspeed Cloud icon set.

Traffic flow

Users reach the static NAT public IP. The redundant virtual router applies the firewall rules and forwards all traffic to cpanel01. Outbound traffic from cpanel01 leaves via that same static NAT IP - egress is allowed by default on net-isolated-std-v1.

The second public IP is held by the network for source NAT. It's allocated automatically when the network is first created and it carries egress for any other VM you later add to the network.

The data volume keeps customer sites on a disk that survives VM rebuilds and can be snapshotted independently of the root disk.

Resource plan

ResourceValueNotes
Zoneau-mel01
Network offeringnet-isolated-std-v1Redundant virtual router, egress allowed, source NAT
Guest subnet10.1.1.0/24Zone default guest CIDR, assigned automatically
Compute offerings-medium-gen22 vCPU / 8 GB. cPanel's minimum is 2 GB; 8 GB is a comfortable production floor
Templatealmalinux-9cPanel supports AlmaLinux 8/9, Rocky 8/9 and Ubuntu 22.04
Root disk40 GBOS and cPanel software
Data volume100 GB, mounted at /homeSites, mail and databases live here
Public IPs2One held by the network for source NAT (allocated automatically), one dedicated to static NAT. A single-IP alternative is covered below

Check these against your account's resource limits before you start - public IPs in particular are a commonly capped resource.

Publishing the VM: static NAT or port forwarding

The isolated network already holds one public IP, allocated automatically for source NAT when the network was first implemented. All outbound traffic from the network leaves via this IP. Inbound traffic is blocked by default.

There are two ways to publish cpanel01. Pick one before you start building.

Option A: port forwardingOption B: static NAT
Public IPs used1 - the existing source NAT IP2 - source NAT plus a dedicated IP
VM outbound IPThe source NAT IP, shared with every VM on the networkThe VM's own static NAT IP
ForwardsOnly the ports you listAll ports
SuitsA single VM where one shared IP is enoughA VM that needs its own identity on the internet, and any network that will grow past one published VM

For a cPanel server, use Option B. Mail reputation depends on a PTR record matching the IP that outbound mail originates from, and the cPanel licence binds to the server's public IP. A dedicated static NAT IP gives you both, and stays correct when you add more VMs to the network.

Option A is documented because it's the right answer for a single VM that doesn't send mail - an internal application server, for example.

Neither option opens the firewall. Static NAT and port forwarding only define where traffic goes. Ingress stays denied until you add the firewall rules below, so a freshly published VM that's unreachable is almost always missing its firewall rules.

Static NAT constraints

Static NAT can't be enabled on the source NAT IP, and once it's enabled on an IP that IP carries no other rules - no port forwarding, no load balancing. See Public IPs and static NAT for the general rules.

Forwarding the whole range 1-65535 under Option A is possible but not recommended. It monopolises the IP, collides with services the virtual router offers on it (such as VPN), and gives you none of the benefits of Option B. If you want everything forwarded, use static NAT.

Firewall plan

Rules are applied to the public IP you publish the VM on. Everything not explicitly allowed here is dropped.

Ports (TCP)ServiceOpen to
80, 443HTTP/HTTPSAnywhere
2082, 2083cPanelAnywhere, or restrict to your users
2095, 2096WebmailAnywhere
25, 465, 587SMTPAnywhere - mail server
110, 995, 143, 993POP3/IMAPAnywhere - mail clients
53 TCP + UDPDNSAnywhere, only if this server is authoritative for its domains
22SSHAdmin IPs only
2086, 2087WHMAdmin IPs only
Request a PTR (reverse DNS) record before sending mail. Raise a support ticket with the IP your server's outbound traffic uses - the static NAT IP under Option B, or the source NAT IP under Option A. Most receivers reject mail from IPs without matching forward and reverse DNS.

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 isolated network

Network → Guest networks → Add network. Select Isolated, name it prod, zone au-mel01, offering net-isolated-std-v1. Leave gateway and netmask blank - the platform assigns 10.1.1.0/24.

3. Deploy the VM

Compute → Instances → Add instance.

  • Template: almalinux-9
  • Compute offering: s-medium-gen2
  • Root disk: 40 GB (override if the dialog offers it)
  • Network: prod
  • SSH key pair: ops-key
  • Name: cpanel01

4. Add the data volume

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

5. Publish the VM to the internet

Follow whichever option you chose in Publishing the VM above.

Option A - port forwarding on the source NAT IP. Go to Network → Guest networks → prod → Public IP addresses, select the IP marked as the source NAT IP, open the Port forwarding tab and add a rule per service: public port, private port, protocol, then select cpanel01. Add a rule for each of 80, 443, 2082-2083, 2095-2096, 25, 465, 587, 110, 995, 143, 993, 22 and 2086-2087. Port forwarding rules have no source address field, so the admin-only restriction on 22 and 2086-2087 comes from the firewall rules in step 6.

Option B - static NAT on a dedicated public IP. Go to Network → Guest networks → prod → Public IP addresses → Acquire new IP. This is a second IP, separate from the source NAT IP. On the new IP, select Static NAT → Enable and choose cpanel01. Outbound traffic from cpanel01 now leaves via this IP rather than the source NAT IP, so this is the IP that needs the PTR record and the one the cPanel licence binds to.

6. Add firewall rules

On the public IP you used in step 5, open the Firewall tab and add the rules from the firewall plan. Use your office or VPN CIDR for the SSH and WHM rules. These rules are what makes traffic pass - without them, both static NAT and port forwarding stay closed.

Both options place cpanel01 behind NAT. In WHM, under Basic WebHost Manager Setup, set the server's public IP so cPanel advertises the correct address in mail headers, DNS records and licence checks.

Installing cPanel

SSH in as the template's default user, then switch to root:

# 1. Prepare the data volume (first attach only)
mkfs.xfs /dev/vdb
echo '/dev/vdb /home xfs defaults 0 0' >> /etc/fstab
mount -a

# 2. Hostname must be a real FQDN that resolves to the public IP
hostnamectl set-hostname server1.example.com

# 3. Update and install
dnf -y update
cd /home && curl -o latest -L https://securedownloads.cpanel.net/latest && sh latest

The installer takes 30 to 60 minutes. When it finishes, log in to WHM at https://<public-ip>:2087 as root and complete the setup wizard. A 15-day trial licence activates automatically against the public IP - buy a licence for that IP before the trial ends.

Install cPanel on a fresh OS only. Don't pre-install Apache, MySQL or mail packages - the installer manages its own stack.

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 isolated network
ZONE=$(cmk list zones name=au-mel01 filter=id | jq -r '.zone[0].id')
NETOFF=$(cmk list networkofferings name=net-isolated-std-v1 filter=id | jq -r '.networkoffering[0].id')
cmk create network name=prod displaytext=prod zoneid=$ZONE networkofferingid=$NETOFF

# 3. Deploy the VM
TMPL=$(cmk list templates templatefilter=executable keyword="almalinux-9" filter=id | jq -r '.template[0].id')
OFFER=$(cmk list serviceofferings name=s-medium-gen2 filter=id | jq -r '.serviceoffering[0].id')
NET=$(cmk list networks name=prod filter=id | jq -r '.network[0].id')
cmk deploy virtualmachine name=cpanel01 zoneid=$ZONE templateid=$TMPL \
  serviceofferingid=$OFFER networkids=$NET keypair=ops-key rootdisksize=40

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

5. Publish the VM - Option B (static NAT)

IP=$(cmk associate ipaddress networkid=$NET | jq -r '.ipaddress.id')
cmk enable staticnat ipaddressid=$IP virtualmachineid=$VM

5. Publish the VM - Option A (port forwarding)

SRCNAT=$(cmk list publicipaddresses associatednetworkid=$NET issourcenat=true filter=id | jq -r '.publicipaddress[0].id')

for p in 80:80 443:443 2082:2083 2095:2096 25:25 465:465 587:587 \
         110:110 995:995 143:143 993:993 22:22 2086:2087; do
  cmk create portforwardingrule ipaddressid=$SRCNAT virtualmachineid=$VM protocol=tcp \
    publicport=${p%%:*} publicendport=${p##*:} \
    privateport=${p%%:*} privateendport=${p##*:} openfirewall=false
done
openfirewall=false matches what the portal does, and keeps firewall rules as the single place where source restrictions live. Setting it to true makes the platform create a matching allow-from-anywhere firewall rule for you, which is wrong for the admin ports.

6. Firewall rules

# Option B: $IP is the static NAT IP from step 5.
# Option A: use $SRCNAT in place of $IP.
ADMIN=1.2.3.4/32   # your admin CIDR

for p in "80:80" "443:443" "2082:2083" "2095:2096" "25:25" "465:465" "587:587" \
         "110:110" "995:995" "143:143" "993:993"; do
  cmk create firewallrule ipaddressid=$IP protocol=tcp \
    startport=${p%%:*} endport=${p##*:} cidrlist=0.0.0.0/0
done

for p in "22:22" "2086:2087"; do
  cmk create firewallrule ipaddressid=$IP protocol=tcp \
    startport=${p%%:*} endport=${p##*:} cidrlist=$ADMIN
done

Deploy with Ansible

This playbook implements Option B (static NAT). It needs the CloudStack collection and its Python dependencies:

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

sshpubkeys is a hard dependency of cs_sshkeypair, which fails at import without it.

Credentials come from the environment. The collection reads these three variables directly, so no api_url / api_key / api_secret arguments are needed on the tasks:

export CLOUDSTACK_ENDPOINT=https://cloud.lightspeedhosting.com.au/client/api
export CLOUDSTACK_KEY=<api key>
export CLOUDSTACK_SECRET=<secret key>
---
# ra01-cpanel.yml - deploy RA-01 basic cPanel server
- name: RA-01 basic VM with cPanel
  hosts: localhost
  connection: local
  gather_facts: false
  vars:
    zone: au-mel01
    admin_cidr: 1.2.3.4/32        # your admin network
    template: almalinux-9
    web_ports: [{s: 80, e: 80}, {s: 443, e: 443}, {s: 2082, e: 2083},
                {s: 2095, e: 2096}, {s: 25, e: 25}, {s: 465, e: 465},
                {s: 587, e: 587}, {s: 110, e: 110}, {s: 995, e: 995},
                {s: 143, e: 143}, {s: 993, e: 993}]
    admin_ports: [{s: 22, e: 22}, {s: 2086, e: 2087}]

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

    - name: Create isolated network
      ngine_io.cloudstack.cs_network:
        name: prod
        zone: "{{ zone }}"
        network_offering: net-isolated-std-v1

    - name: Deploy instance
      ngine_io.cloudstack.cs_instance:
        name: cpanel01
        zone: "{{ zone }}"
        template: "{{ template }}"
        service_offering: s-medium-gen2
        networks: [prod]
        ssh_key: ops-key
        root_disk_size: 40

    - name: Create and attach data volume
      ngine_io.cloudstack.cs_volume:
        name: cpanel01-data
        zone: "{{ zone }}"
        disk_offering: Custom
        size: 100
        vm: cpanel01
        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 network
      ngine_io.cloudstack.cs_ip_address:
        zone: "{{ zone }}"
        network: prod
        tags:
          - key: purpose
            value: cpanel01-staticnat
      register: pub

    - name: Enable static NAT
      ngine_io.cloudstack.cs_staticnat:
        ip_address: "{{ pub.ip_address }}"
        vm: cpanel01
        zone: "{{ zone }}"

    - name: Public firewall rules
      ngine_io.cloudstack.cs_firewall:
        ip_address: "{{ pub.ip_address }}"
        zone: "{{ zone }}"
        protocol: tcp
        start_port: "{{ item.s }}"
        end_port: "{{ item.e }}"
        cidrs: [0.0.0.0/0]
      loop: "{{ web_ports }}"

    - name: Admin firewall rules
      ngine_io.cloudstack.cs_firewall:
        ip_address: "{{ pub.ip_address }}"
        zone: "{{ zone }}"
        protocol: tcp
        start_port: "{{ item.s }}"
        end_port: "{{ item.e }}"
        cidrs: ["{{ admin_cidr }}"]
      loop: "{{ admin_ports }}"

    - name: Show public IP
      ansible.builtin.debug:
        msg: "cpanel01 is reachable at {{ pub.ip_address }}"

Installing cPanel itself is host configuration, not infrastructure. Run it as a second play against the new IP, or pass the install commands above as user data.

For Option A instead

Drop the cs_ip_address and cs_staticnat tasks and use cs_portforward against the source NAT IP. Its open_firewall parameter defaults to false, matching the portal, so keep the firewall tasks:

    - name: Port forward to cpanel01
      ngine_io.cloudstack.cs_portforward:
        ip_address: "{{ source_nat_ip }}"
        zone: "{{ zone }}"
        vm: cpanel01
        protocol: tcp
        public_port: "{{ item.s }}"
        public_end_port: "{{ item.e }}"
        private_port: "{{ item.s }}"
        private_end_port: "{{ item.e }}"
      loop: "{{ web_ports + admin_ports }}"

Deploy with Terraform

This configuration implements Option B (static NAT). Unlike the portal, the provider requires cidr on cloudstack_network, so the guest subnet is stated explicitly rather than left to the zone default. Keep it inside the zone's guest CIDR range.

# ra01-cpanel.tf - deploy RA-01 basic cPanel server
terraform {
  required_providers {
    cloudstack = {
      source  = "cloudstack/cloudstack"
      version = "~> 0.5"
    }
  }
}

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
}

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

resource "cloudstack_network" "prod" {
  name             = "prod"
  zone             = "au-mel01"
  network_offering = "net-isolated-std-v1"
  cidr             = "10.1.1.0/24"
}

resource "cloudstack_instance" "cpanel01" {
  name             = "cpanel01"
  zone             = "au-mel01"
  template         = "almalinux-9"
  service_offering = "s-medium-gen2"
  network_id       = cloudstack_network.prod.id
  keypair          = cloudstack_ssh_keypair.ops.name
  root_disk_size   = 40
  expunge          = true
}

resource "cloudstack_disk" "data" {
  name               = "cpanel01-data"
  zone               = "au-mel01"
  disk_offering      = "Custom"
  size               = 100
  attach             = true
  virtual_machine_id = cloudstack_instance.cpanel01.id
}

resource "cloudstack_ipaddress" "pub" {
  zone       = "au-mel01"
  network_id = cloudstack_network.prod.id
}

resource "cloudstack_static_nat" "cpanel01" {
  ip_address_id      = cloudstack_ipaddress.pub.id
  virtual_machine_id = cloudstack_instance.cpanel01.id
}

resource "cloudstack_firewall" "cpanel01" {
  ip_address_id = cloudstack_ipaddress.pub.id

  rule {
    cidr_list = ["0.0.0.0/0"]
    protocol  = "tcp"
    ports     = ["80", "443", "2082-2083", "2095-2096",
                 "25", "465", "587", "110", "995", "143", "993"]
  }

  rule {
    cidr_list = [var.admin_cidr]
    protocol  = "tcp"
    ports     = ["22", "2086-2087"]
  }
}

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

For Option A instead

Replace the cloudstack_ipaddress, cloudstack_static_nat and cloudstack_firewall resources with cloudstack_port_forward blocks pointed at the network's source NAT IP, plus a cloudstack_firewall on the same IP for the source restrictions.

Verify

  1. ssh <template-user>@<public-ip> works from your admin CIDR, and times out from anywhere else.
  2. curl -I http://<public-ip> returns a response once cPanel is installed.
  3. WHM login at https://<public-ip>:2087 works from your admin CIDR only.
  4. df -h /home shows the 100 GB data volume.
  5. Outbound works from the VM: curl -I https://securedownloads.cpanel.net.

Day 2 operations

  • Snapshots. Schedule recurring volume snapshots on both the root and data volumes, under Storage → Volumes → Snapshots. Take one before every cPanel version upgrade.
  • Scaling up. Stop the VM and change the compute offering - s-large-gen2 for 4 vCPU / 16 GB. Dynamic scaling is enabled on gen2 offerings for online upsizing where the guest supports it. See resizing a VM.
  • Backups. cPanel's own backup tool can target remote destinations. Pair on-server backups on the data volume with an off-server destination such as our S3-compatible object storage.
  • Don't change the public IP. The cPanel licence and mail reputation are both tied to it. If the VM is rebuilt, re-enable static NAT from the same IP to the new VM.
  • Adding a second VM to this network. It shares the source NAT IP for egress and needs its own publishing decision: another static NAT IP, or port forwarding rules on the source NAT IP using ports that don't collide with cpanel01.