Getting Started with Terraform
This guide stands up a minimal Frostmoln configuration with Terraform.
1. Configure the provider
Create main.tf:
terraform {
required_providers {
frostmoln = {
source = "frostmoln/frostmoln"
}
}
}
provider "frostmoln" {
# Credentials are read from provider arguments or environment variables.
# Generate an API key in the portal under Settings -> API Keys.
}Provide your API key via the environment so it stays out of source control:
export FROSTMOLN_API_KEY=fmk_...Selecting a tenant
By default the provider manages resources in your account's default tenant. To target another tenant, set tenant_id on the provider — or the FROSTMOLN_TENANT_ID environment variable:
provider "frostmoln" {
tenant_id = "00000000-0000-0000-0000-000000000000"
}Targeting a tenant other than your default requires an fm CLI / OIDC session (fm auth login) whose user belongs to multiple tenants — an API key is bound to a single tenant. One tenant per provider instance; to manage more than one tenant in the same configuration, declare a second provider with an alias. Don't re-point tenant_id on a provider that already manages resources — use a separate aliased provider (and terraform state mv), or Terraform tries to recreate them in the new tenant.
2. Define resources
The smallest configuration that actually gives you a machine you can log into is a VPC, a gateway for it, a subnet, a security group, an SSH key and the instance itself. Add this to main.tf:
resource "frostmoln_vpc" "main" {
name = "tf-vpc"
cidr = "10.0.0.0/16"
}
# A VPC is an isolated network until a gateway is declared for it: no outbound
# internet and no platform DNS. Leave this out and the instance below still
# boots, but cloud-init cannot reach anything and name resolution fails.
resource "frostmoln_gateway" "main" {
vpc_id = frostmoln_vpc.main.id
mode = "public_ip"
}
resource "frostmoln_subnet" "main" {
name = "tf-subnet"
vpc_id = frostmoln_vpc.main.id
cidr = "10.0.1.0/24"
zone = "falkenberg"
}
resource "frostmoln_security_group" "web" {
name = "tf-web"
vpc_id = frostmoln_vpc.main.id
}
resource "frostmoln_security_group_rule" "ssh" {
security_group_id = frostmoln_security_group.web.id
direction = "ingress"
protocol = "tcp"
port_range_min = 22
port_range_max = 22
remote_cidr = "203.0.113.4/32" # your own address, not 0.0.0.0/0
description = "SSH from the office"
}
resource "frostmoln_ssh_key" "laptop" {
name = "my-laptop"
public_key = file(pathexpand("~/.ssh/id_ed25519.pub"))
}
data "frostmoln_image" "ubuntu" {
name = "ubuntu-24.04"
}
resource "frostmoln_instance" "web" {
name = "tf-web"
image_id = data.frostmoln_image.ubuntu.id
flavor_id = "gp1.small"
zone = "falkenberg"
vpc_id = frostmoln_vpc.main.id
subnet_id = frostmoln_subnet.main.id
security_groups = [frostmoln_security_group.web.id]
ssh_key_names = [frostmoln_ssh_key.laptop.name]
}
# An address of its own, so you can reach the instance from outside. The
# depends_on is what makes `terraform destroy` work: a gateway refuses to be
# removed while public IPs in its VPC still point at it, and without this edge
# nothing tells Terraform to release the address before the gateway.
resource "frostmoln_public_ip" "web" {
instance_id = frostmoln_instance.web.id
depends_on = [frostmoln_gateway.main]
}
output "web_address" {
value = frostmoln_public_ip.web.address
}A few things in there are worth knowing before you run it.
- A VPC has no way to the internet until a gateway is declared for it.
frostmoln_vpccarries no connectivity argument on purpose — in Terraform the connectivity choice is the presence or absence offrostmoln_gateway. A VPC without one is isolated in both directions, and because platform DNS and managed-service connectivity are reached over the same path, instances in it cannot resolve names either. That is the usual reason a firstterraform applylooks like it worked and then cloud-init fails on every package it tries to install. See Gateway. image_idandflavor_idtake IDs, not display names. A flavor's ID is its SKU, sogp1.smallcan be written literally — see what is on offer withfm compute flavor list, or read it in Terraform with thefrostmoln_flavor/frostmoln_flavorsdata sources. Images are identified by UUID, so look one up by name with thefrostmoln_imagedata source as above.security_groupsdecides what can reach in. Omit it, or set it to[], and the instance falls back to the tenant'sdefaultgroup, which allows all outbound traffic but admits inbound only from instances in that same group — so nothing from the internet, SSH included. A group you create yourself starts the same way round: outbound allowed, no inbound rules at all, so the inbound you want has to be written out asfrostmoln_security_group_rule.sshdoes above.ssh_key_namestakes key names.frostmoln_ssh_keyis identified by its name within a tenant, sofrostmoln_ssh_key.laptop.nameis the value to pass. Terraform'sfile()does not expand~by itself — wrap the path inpathexpand()as above, or give an absolute path.zoneis optional. Leave it out and the platform picks an availability zone and records it in state. Keep the subnet and the instance in the same one.
3. Apply
terraform init
terraform plan
terraform applyTerraform tracks state, so subsequent apply runs only change what differs.
4. Tear it down
terraform destroy removes the resources again, with three deliberate stops in the way:
- The gateway refuses to be destroyed until the configuration says the loss of connectivity is intended. Add
acknowledge_connectivity_loss = truetofrostmoln_gateway.main,terraform applythat, and then destroy — and put the line back only when you next need it, since atrueleft behind disarms the guard for the life of the resource. - The gateway also refuses while public IPs in its VPC still depend on it. The
depends_onabove settles that for the one address in this configuration, but any other public IP in the same VPC — a load balancer's, or one this configuration does not manage — has to be disassociated first. See Public IPs. - Destroying
frostmoln_public_ipreleases the address for good. It returns to a shared pool and is re-issued to whoever asks next, so an address that is published in DNS or sitting in someone's allow-list is worth protecting with Terraform's ownlifecycle { prevent_destroy = true }.