Professional Services Infrastructure as Code

Server provisioning with Terraform & OCI

One Always Free Oracle Cloud virtual machine — Ubuntu 24.04 LTS with a public IP, reachable on SSH, HTTP and HTTPS — declared entirely in version-controlled .tf files. Like Azure, OCI has no default network, so the configuration builds the whole stack itself: VCN, internet gateway, route table, security list and subnet. Scoped to exactly one VM, with no credentials in the code. terraform apply to create it, terraform destroy to make it disappear.

Back to Professional Services
1
VM provisioned
6
Resources created
$0
On Always Free
7
Config files

What this is

This is the write-up that accompanies the studio's OCI Terraform guide — a step-by-step walkthrough of an OCI-only configuration (terraform/oci-freetier-vm) that provisions exactly one Always Free Linux VM running the latest Ubuntu 24.04 LTS, with a public IP and a security list open for SSH (22), HTTP (80) and HTTPS (443). The guide explains what every file does, what every entry inside each file is for, how to obtain each value, and the exact commands used to apply it.

OCI (Oracle Cloud Infrastructure) is the most "OCID-heavy" of the clouds — nearly everything is identified by a long ocid1.<type>.oc1..<hash> string. And like Azure (and unlike AWS), OCI has no default network, so the module builds the whole stack: VCN → internet gateway → route table → security list → subnet → instance. The default shape is the always-free AMD x86 micro VM.Standard.E2.1.Micro; the ARM VM.Standard.A1.Flex is an option (more capable, but frequently capacity-limited). It stores no credentials: auth and request signing come from your ~/.oci/config profile, and only your public SSH key is ever injected. The example .tf files below are the real configuration from the guide.

Terraform in 30 seconds

Terraform is infrastructure as code: you declare the resources you want in .tf files and Terraform works out the API calls to create, change, or delete them to match. The pieces you'll see below:

  • Provider — a plugin that talks to one platform's API (here, oci).
  • Resource — a thing Terraform creates and manages (a VCN, a subnet, the instance).
  • Data source — a read-only lookup of something that already exists (the availability domain, the latest image).
  • Variable — a typed input, so the same code is reusable per run.
  • Output — a value Terraform prints after an apply (e.g. the instance's public IP).
  • State — Terraform's record of what it created (terraform.tfstate), kept out of git because it can hold sensitive values.

What it provisions

Terraform apply creating one Always Free OCI VM and its full network stack Running terraform apply from your machine authenticates and signs requests using your OCI CLI config profile. Read-only data sources look up an availability domain and the latest Ubuntu image for the chosen shape. Because OCI has no default network, Terraform builds the whole stack: a VCN, an internet gateway, a route table to that gateway, a security list allowing inbound SSH, HTTP and HTTPS, and a subnet wired to both. It then creates a single instance with a public IP in that subnet. terraform apply auth: ~/.oci/config injects public SSH key data sources (read-only) availability_domains → first AD core_images → latest Ubuntu for the shape OCI · VCN (internet gateway + route table) security list in 22 / 80 / 443 · out all subnet route table + security list oci_core_instance · E2.1.Micro Ubuntu 24.04 · public IP · Always Free exactly one — no count / for_each
One provider, read-only lookups so nothing is hardcoded, and the whole network chain OCI needs — VCN, gateway, route table, security list and subnet — around a single public VM.

The configuration, file by file

Terraform reads all .tf files in a directory as one configuration, so the split below is purely for human organization. The directory contains:

FileWhat it's for
versions.tfTerraform + provider version pins, so the config is reproducible
variables.tfThe input variables (the knobs); compartment_id is the only required one
main.tfThe provider, the read-only lookups, the networking, and the instance
outputs.tfValues printed after an apply (instance OCID, public IP, image OCID, a ready-to-run SSH command)
terraform.tfvars.exampleSample variable values — copy to terraform.tfvars, set compartment_id (no secrets)
.gitignoreKeeps state, secrets, plugins and terraform.tfvars out of git
README.mdThe module's own quick-start + troubleshooting (this page is the deeper companion)

versions.tf — version constraints

Refuse to run on a Terraform CLI older than 1.5, and pin the Oracle provider to any 6.x (but not 7.0) so a surprise breaking upgrade can't sneak in.

versions.tf
terraform {
  required_version = ">= 1.5.0"

  required_providers {
    oci = {
      source  = "oracle/oci"
      version = "~> 6.0"
    }
  }
}

variables.tf — the inputs

Every variable has a description and a type; all but compartment_id have a default, so the config runs with just your compartment OCID (the tenancy root is fine for Always Free). The shape picks the VM size — the default AMD micro allocates reliably; the Flex ARM shape uses ocpus / memory_in_gbs.

variables.tf
variable "compartment_id" {
  description = "OCID of the compartment to deploy into. For Always Free experimentation the tenancy root compartment is fine (use the tenancy OCID). Get one with: oci iam compartment list --query 'data[].id'"
  type        = string
}

variable "region" {
  description = "OCI region. Auth/region otherwise come from the OCI CLI profile."
  type        = string
  default     = "us-phoenix-1"
}

variable "config_file_profile" {
  description = "Profile in ~/.oci/config used for auth/signing."
  type        = string
  default     = "DEFAULT"
}

variable "name" {
  description = "Display-name prefix applied to the created resources."
  type        = string
  default     = "freetier-vm"
}

variable "shape" {
  description = "Compute shape. Default VM.Standard.E2.1.Micro is the always-free AMD x86 micro (1 OCPU, 1 GB) — the more reliable allocator. For ARM use VM.Standard.A1.Flex (also Always Free, up to 4 OCPU / 24 GB, uses ocpus/memory_in_gbs) but it is frequently capacity-constrained ('Out of host capacity')."
  type        = string
  default     = "VM.Standard.E2.1.Micro"
}

variable "ocpus" {
  description = "OCPUs — only used for Flex shapes (e.g. A1.Flex). Always Free A1 allows up to 4 total."
  type        = number
  default     = 1
}

variable "memory_in_gbs" {
  description = "Memory in GB — only used for Flex shapes. Always Free A1 allows up to 24 GB total."
  type        = number
  default     = 6
}

variable "operating_system" {
  description = "Image OS to look up (matched to the shape's architecture automatically)."
  type        = string
  default     = "Canonical Ubuntu"
}

variable "operating_system_version" {
  description = "Image OS version to look up."
  type        = string
  default     = "24.04"
}

variable "ssh_user" {
  description = "Default login user for the chosen image (Ubuntu = ubuntu, Oracle Linux = opc). Used only for the ssh_command output."
  type        = string
  default     = "ubuntu"
}

variable "ssh_public_key_path" {
  description = "Path to the SSH PUBLIC key injected into the instance. The matching PRIVATE key stays on your machine and is never uploaded or committed."
  type        = string
  default     = "~/.ssh/id_rsa.pub"
}

variable "ssh_ingress_cidr" {
  description = "CIDR allowed to reach SSH (port 22). Defaults to the whole internet; tighten to your IP (e.g. \"203.0.113.4/32\")."
  type        = string
  default     = "0.0.0.0/0"
}

variable "vcn_cidr" {
  description = "Address space for the VCN."
  type        = string
  default     = "10.0.0.0/16"
}

variable "subnet_cidr" {
  description = "Address prefix for the subnet."
  type        = string
  default     = "10.0.1.0/24"
}

main.tf — the provider, lookups, and resources

The heart of the configuration, read top to bottom: configure the oci provider from your CLI profile; detect Flex shapes with a local; look up an availability domain and the latest image; build the networking (VCN → internet gateway → route table → security list → subnet); then declare the single instance, emitting the shape_config block only for Flex shapes.

main.tf
# SCOPE: this configuration is OCI-only and provisions exactly ONE VM.
# It declares only the oracle/oci provider, and the single oci_core_instance
# below uses no count/for_each. It is intentionally NOT a multi-cloud module and
# will not deploy to AWS/Azure/GCP — those clouds get their own configurations.
provider "oci" {
  # Auth + signing come from your OCI CLI config (~/.oci/config) profile.
  config_file_profile = var.config_file_profile
  region              = var.region
}

# Flex shapes (e.g. *.A1.Flex) require a shape_config (ocpus/memory); fixed
# shapes (e.g. E2.1.Micro) must NOT have one. Detect Flex by the shape name.
locals {
  is_flex = length(regexall("Flex", var.shape)) > 0
}

# Pick an availability domain in the compartment.
data "oci_identity_availability_domains" "ads" {
  compartment_id = var.compartment_id
}

# Latest image matching the OS + the shape's architecture (passing `shape` makes
# OCI return only images compatible with it, so x86 vs ARM is handled for us).
data "oci_core_images" "this" {
  compartment_id           = var.compartment_id
  operating_system         = var.operating_system
  operating_system_version = var.operating_system_version
  shape                    = var.shape
  sort_by                  = "TIMECREATED"
  sort_order               = "DESC"
}

# Networking: OCI has no default VCN, so build the whole stack —
# VCN -> internet gateway -> route table -> security list -> subnet.
resource "oci_core_vcn" "this" {
  compartment_id = var.compartment_id
  cidr_blocks    = [var.vcn_cidr]
  display_name   = "${var.name}-vcn"
}

resource "oci_core_internet_gateway" "this" {
  compartment_id = var.compartment_id
  vcn_id         = oci_core_vcn.this.id
  display_name   = "${var.name}-igw"
  enabled        = true
}

resource "oci_core_route_table" "this" {
  compartment_id = var.compartment_id
  vcn_id         = oci_core_vcn.this.id
  display_name   = "${var.name}-rt"

  route_rules {
    destination       = "0.0.0.0/0"
    network_entity_id = oci_core_internet_gateway.this.id
  }
}

# Security list: inbound SSH (restrictable), HTTP and HTTPS; all outbound.
resource "oci_core_security_list" "this" {
  compartment_id = var.compartment_id
  vcn_id         = oci_core_vcn.this.id
  display_name   = "${var.name}-sl"

  egress_security_rules {
    destination = "0.0.0.0/0"
    protocol    = "all"
  }

  ingress_security_rules {
    protocol = "6" # TCP
    source   = var.ssh_ingress_cidr
    tcp_options {
      min = 22
      max = 22
    }
  }

  ingress_security_rules {
    protocol = "6"
    source   = "0.0.0.0/0"
    tcp_options {
      min = 80
      max = 80
    }
  }

  ingress_security_rules {
    protocol = "6"
    source   = "0.0.0.0/0"
    tcp_options {
      min = 443
      max = 443
    }
  }
}

resource "oci_core_subnet" "this" {
  compartment_id    = var.compartment_id
  vcn_id            = oci_core_vcn.this.id
  cidr_block        = var.subnet_cidr
  display_name      = "${var.name}-subnet"
  route_table_id    = oci_core_route_table.this.id
  security_list_ids = [oci_core_security_list.this.id]
}

# Exactly one VM: a single resource with no count/for_each. Do not add count or
# for_each here — this module is deliberately scoped to one instance.
resource "oci_core_instance" "this" {
  availability_domain = data.oci_identity_availability_domains.ads.availability_domains[0].name
  compartment_id      = var.compartment_id
  display_name        = var.name
  shape               = var.shape

  # Only emitted for Flex shapes (A1.Flex etc.); omitted for fixed micro shapes.
  dynamic "shape_config" {
    for_each = local.is_flex ? [1] : []
    content {
      ocpus         = var.ocpus
      memory_in_gbs = var.memory_in_gbs
    }
  }

  create_vnic_details {
    subnet_id        = oci_core_subnet.this.id
    assign_public_ip = true
  }

  source_details {
    source_type = "image"
    source_id   = data.oci_core_images.this.images[0].id
  }

  # Key-only login: inject the public key via cloud-init metadata.
  metadata = {
    ssh_authorized_keys = file(pathexpand(var.ssh_public_key_path))
  }
}

outputs.tf — what you get back

After an apply, Terraform prints these (and terraform output <name> re-prints any one). The ssh_command builds the private-key path from the public-key path and uses the ssh_user.

outputs.tf
output "instance_id" {
  description = "OCID of the instance."
  value       = oci_core_instance.this.id
}

output "public_ip" {
  description = "Public IPv4 address of the instance."
  value       = oci_core_instance.this.public_ip
}

output "image_id" {
  description = "OCID of the image the instance was launched from."
  value       = data.oci_core_images.this.images[0].id
}

output "ssh_command" {
  description = "Ready-to-run SSH command."
  value       = "ssh -i ${replace(pathexpand(var.ssh_public_key_path), ".pub", "")} ${var.ssh_user}@${oci_core_instance.this.public_ip}"
}

terraform.tfvars.example & .gitignore — safe by default

The example tfvars is a copy-me template containing no secrets — only your compartment OCID, the region, the profile name, and the path to your public key. The real terraform.tfvars and all Terraform state are gitignored, because state can contain sensitive values and should never be committed.

terraform.tfvars.example
# Copy to terraform.tfvars and adjust. The only required value is compartment_id.
# No secrets belong here (auth comes from ~/.oci/config). terraform.tfvars is
# gitignored so your local choices stay out of the repo.

# REQUIRED — where to create resources. Tenancy root compartment is fine for
# Always Free. Get it with: oci iam compartment list --query 'data[].id'
# (or use your tenancy OCID from ~/.oci/config).
compartment_id = "ocid1.compartment.oc1..xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"

region              = "us-phoenix-1"
config_file_profile = "DEFAULT"
name                = "freetier-vm"

# Default: always-free AMD x86 micro (most reliable). For ARM, switch to A1.Flex
# and set ocpus/memory_in_gbs (Always Free A1 allows up to 4 OCPU / 24 GB total) —
# but A1 is often capacity-constrained ("Out of host capacity"); retry or pick
# another AD/region.
shape = "VM.Standard.E2.1.Micro"
# shape         = "VM.Standard.A1.Flex"
# ocpus         = 1
# memory_in_gbs = 6

ssh_public_key_path = "~/.ssh/id_rsa.pub"
ssh_user            = "ubuntu"

# Tighten to your own IP for a smaller attack surface, e.g. "203.0.113.4/32".
ssh_ingress_cidr = "0.0.0.0/0"
.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 compartment OCID).
.terraform/
.terraform.lock.hcl
*.tfstate
*.tfstate.*
crash.log
*.tfplan
terraform.tfvars
*.auto.tfvars

No secrets in the repo. OCI auth and request signing come from your ~/.oci/config profile (and its private API key, which stays on your machine), never the code; only the public SSH key is injected; and state plus local variable files are gitignored. Every change to the infrastructure is a reviewable diff in git.

The apply workflow

The terraform init, plan, apply, destroy workflow on OCI The workflow runs left to right: copy the example tfvars and set your compartment OCID, terraform init downloads the oci provider, terraform apply creates the VCN, networking and one instance and prints the SSH command, and terraform destroy tears everything back down when you are done. set compartment_id in terraform.tfvars terraform init download provider terraform apply create + print outputs ssh ubuntu@… from ssh_command output terraform destroy destroy when you're done — and to free the VCN (Always Free allows 2 per region)
The same four commands every time — and because it's all declarative, re-running plan shows exactly what would change before you apply it.
apply & tear down
cd terraform/oci-freetier-vm

# auth must already work: oci os ns get succeeds
cp terraform.tfvars.example terraform.tfvars   # set compartment_id

terraform init      # downloads the oci provider into .terraform/
terraform plan      # preview: VCN/networking + one instance
terraform apply     # type 'yes' to actually create them

terraform output ssh_command   # the exact command to connect

terraform destroy   # type 'yes' — removes the instance and VCN

Free, within the limits. The always-free shapes (E2.1.Micro, and A1.Flex within 4 OCPU / 24 GB), the VCN and a public IP are $0 within Always Free limits. Watch the per-region VCN limit of 2 — a stray VCN can block the next apply — so terraform destroy when you're finished.

This is the OCI sibling of the AWS configuration; the same VM exists for Azure and GCP, and the multi-cloud configuration stands up all four at once. Once a box is up, the same fleet is patched and hardened with the Ansible playbooks, and a push to main becomes a multi-OS build and release through the CI/CD pipeline.

Want your cloud infrastructure described in code, not clicked together in a console?

Reproducible, reviewable provisioning you can stand up and tear down on demand — let's talk.