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.
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
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:
| File | What it's for |
|---|---|
versions.tf | Terraform + provider version pins, so the config is reproducible |
variables.tf | The input variables (the knobs); compartment_id is the only required one |
main.tf | The provider, the read-only lookups, the networking, and the instance |
outputs.tf | Values printed after an apply (instance OCID, public IP, image OCID, a ready-to-run SSH command) |
terraform.tfvars.example | Sample variable values — copy to terraform.tfvars, set compartment_id (no secrets) |
.gitignore | Keeps state, secrets, plugins and terraform.tfvars out of git |
README.md | The 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.
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.
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.
# 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.
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.
# 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"
# 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
plan shows exactly what would change before you apply it.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.