Terraform Examples for Oracle Cloud (OCI) from AlmaLinux 10

Oracle Cloud Infrastructure (OCI) is the last stop in this Terraform series, after Terraform Examples for Amazon AWS from AlmaLinux 10, Terraform Examples for Google Cloud (GCP) from AlmaLinux 10 and Terraform Examples for Microsoft Azure from AlmaLinux 10. This guide builds the same small lab from an AlmaLinux 10 workstation: a virtual cloud network (VCN) with an internet gateway, route table and security list, an AlmaLinux 10 instance from the official AlmaLinux Marketplace image with a reserved public IP and nginx installed by cloud-init, a private Object Storage bucket with versioning, and remote Terraform state stored in Object Storage with the native oci backend.

Versions at the time of writing: Terraform 1.17.0, the oracle/oci provider 9.x (9.11.0) and AlmaLinux 10.2. Every example here passes terraform validate and terraform fmt -check. The same steps work on AlmaLinux 9.x, Rocky Linux, Oracle Linux, RHEL and CentOS Stream.

What you will build

  • A VCN (10.50.0.0/16) with one public subnet (10.50.1.0/24), an internet gateway and a route table
  • A security list allowing SSH only from your IP, HTTP from the internet, and the ICMP messages OCI needs for path MTU discovery
  • A VM.Standard.E5.Flex instance (1 OCPU, 8 GB) running the newest AlmaLinux OS 10 build from the Oracle Cloud Marketplace, with the image terms accepted by Terraform, SSH key login for opc, legacy IMDSv1 endpoints disabled, and nginx installed by cloud-init
  • A reserved public IP that survives an instance rebuild
  • An Object Storage bucket with no public access and object versioning
  • Remote Terraform state in Object Storage, locked by the backend itself

Prerequisites

  • An AlmaLinux 10 (or 9.x) machine with sudo access and outbound HTTPS
  • An OCI tenancy (the Free Tier works) and a user who can manage virtual networks, instances, Object Storage and Marketplace subscriptions in one compartment. Tenancy administrators already can.
  • A compartment for the lab. Avoid the root compartment so the lab is easy to find and clean up.
  • An SSH key pair on your workstation. If you need one, see Configure SSH Key Authentication Only.
  • A small budget. An E5 Flex instance with 1 OCPU costs a few cents an hour, so destroy the lab when you are done. On a Free Tier account you can switch to an Always Free shape instead.

Step 1: Install Terraform from the HashiCorp repository

HashiCorp publishes an official RPM repository for RHEL-compatible systems:

sudo dnf install -y dnf-plugins-core
sudo dnf config-manager --add-repo https://rpm.releases.hashicorp.com/RHEL/hashicorp.repo
sudo dnf install -y terraform
terraform -version

Step 2: Install the OCI CLI

Terraform does not need the CLI, but it is the easiest way to create the API key config and to verify what Terraform built. Install it into a Python virtual environment so it never touches system packages:

sudo dnf install -y python3 python3-pip jq
python3 -m venv ~/.venvs/oci-cli
~/.venvs/oci-cli/bin/pip install --upgrade pip oci-cli
mkdir -p ~/.local/bin
ln -sf ~/.venvs/oci-cli/bin/oci ~/.local/bin/oci
oci --version

Step 3: Create an API key and the OCI config file

The oci provider reads ~/.oci/config, the same file the CLI uses, so no secret goes into your Terraform code. Find your tenancy OCID and user OCID in the Console (Profile menu, then Tenancy and My profile), then run the setup wizard. It generates an API signing key pair and writes the config:

oci setup config
# Accept the default location ~/.oci/config
# Enter your user OCID, tenancy OCID and region (for example us-chicago-1)
# Answer Y to generate a new API signing RSA key pair

chmod 600 ~/.oci/config ~/.oci/oci_api_key.pem
cat ~/.oci/oci_api_key_public.pem

In the Console, open My profile, go to API keys, choose Add API key, and paste the public key. The fingerprint OCI shows must match the fingerprint line in ~/.oci/config. Then test the login and look up the IDs you need:

oci iam region-subscription list --query 'data[]."region-name"' --output table
oci iam compartment list --compartment-id-in-subtree true --all \
  --query 'data[].{name:name, id:id}' --output table
oci iam availability-domain list --query 'data[].name' --output table

For CI pipelines or jobs that run on OCI itself, prefer instance principals, resource principals or workload identity federation over a long-lived API key. The provider supports them with its auth argument.

Step 4: Create the project layout

mkdir -p ~/terraform/oci-lab && cd ~/terraform/oci-lab
touch versions.tf variables.tf network.tf image.tf compute.tf storage.tf outputs.tf terraform.tfvars

Example 1: Pin Terraform and the provider

versions.tf pins the oci provider to the 9.x series and points it at a profile in ~/.oci/config. Terraform 1.12 or newer is required for the oci state backend used in Example 8:

terraform {
  required_version = ">= 1.12"

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

provider "oci" {
  region              = var.region
  config_file_profile = var.oci_profile
}

Example 2: Input variables

variables.tf. The default listing OCID is the official AlmaLinux OS 10 (x86_64) Marketplace listing published on the AlmaLinux wiki; the same OCID works in every region:

variable "region" {
  description = "OCI region identifier, for example us-chicago-1"
  type        = string
  default     = "us-chicago-1"
}

variable "oci_profile" {
  description = "Profile name in ~/.oci/config"
  type        = string
  default     = "DEFAULT"
}

variable "compartment_ocid" {
  description = "OCID of the compartment that holds the lab"
  type        = string
}

variable "name" {
  description = "Name prefix used for every resource"
  type        = string
  default     = "zwiegnet-lab"
}

variable "admin_cidr" {
  description = "Your public IP in CIDR form, allowed to SSH to the instance"
  type        = string

  validation {
    condition     = can(cidrhost(var.admin_cidr, 0))
    error_message = "admin_cidr must be a valid CIDR block, for example 203.0.113.25/32."
  }
}

variable "ssh_public_key_path" {
  description = "Path to the SSH public key to install for the opc user"
  type        = string
  default     = "~/.ssh/id_ed25519.pub"
}

variable "almalinux_listing_id" {
  description = "Oracle Cloud Marketplace listing for AlmaLinux OS 10 (x86_64)"
  type        = string
  default     = "ocid1.mktpublisting.oc1.iad.amaaaaaac2u4zjyadqutnu6o4pr54h5c6ctigrnrtenr63qwk4pr7gqykpgq"
}

variable "shape" {
  description = "Flexible VM shape"
  type        = string
  default     = "VM.Standard.E5.Flex"
}

variable "ocpus" {
  type    = number
  default = 1
}

variable "memory_gbs" {
  type    = number
  default = 8
}

variable "freeform_tags" {
  description = "Tags added to every resource"
  type        = map(string)
  default = {
    project    = "zwiegnet-lab"
    managed_by = "terraform"
  }
}

Put your own values in terraform.tfvars:

curl -s https://checkip.amazonaws.com

cat > terraform.tfvars <<'EOF'
region           = "us-chicago-1"
compartment_ocid = "ocid1.compartment.oc1..aaaaaaaaexampleexampleexample"
admin_cidr       = "203.0.113.25/32"
EOF

Add terraform.tfvars to .gitignore if the project lives in a shared repository.

Example 3: VCN, internet gateway, route table and security list

OCI networking has more moving parts than the other clouds: the VCN does not reach the internet until you add an internet gateway and a route rule that points at it, and traffic is filtered by a security list attached to the subnet (or by network security groups attached to VNICs). Protocols are given by number: 6 is TCP and 1 is ICMP. network.tf:

resource "oci_core_vcn" "lab" {
  compartment_id = var.compartment_ocid
  display_name   = "${var.name}-vcn"
  cidr_blocks    = ["10.50.0.0/16"]
  dns_label      = "zwlab"
  freeform_tags  = var.freeform_tags
}

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

resource "oci_core_route_table" "public" {
  compartment_id = var.compartment_ocid
  vcn_id         = oci_core_vcn.lab.id
  display_name   = "${var.name}-public-rt"
  freeform_tags  = var.freeform_tags

  route_rules {
    destination       = "0.0.0.0/0"
    destination_type  = "CIDR_BLOCK"
    network_entity_id = oci_core_internet_gateway.lab.id
  }
}

resource "oci_core_security_list" "public" {
  compartment_id = var.compartment_ocid
  vcn_id         = oci_core_vcn.lab.id
  display_name   = "${var.name}-public-sl"
  freeform_tags  = var.freeform_tags

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

  # SSH from your IP only (protocol 6 = TCP)
  ingress_security_rules {
    source   = var.admin_cidr
    protocol = "6"
    tcp_options {
      min = 22
      max = 22
    }
  }

  # HTTP from anywhere
  ingress_security_rules {
    source   = "0.0.0.0/0"
    protocol = "6"
    tcp_options {
      min = 80
      max = 80
    }
  }

  # ICMP type 3 code 4 keeps path MTU discovery working (protocol 1 = ICMP)
  ingress_security_rules {
    source   = "0.0.0.0/0"
    protocol = "1"
    icmp_options {
      type = 3
      code = 4
    }
  }
}

resource "oci_core_subnet" "public" {
  compartment_id             = var.compartment_ocid
  vcn_id                     = oci_core_vcn.lab.id
  display_name               = "${var.name}-public"
  cidr_block                 = "10.50.1.0/24"
  dns_label                  = "web"
  route_table_id             = oci_core_route_table.public.id
  security_list_ids          = [oci_core_security_list.public.id]
  prohibit_public_ip_on_vnic = false
  freeform_tags              = var.freeform_tags
}

Security lists are stateful by default, so return traffic for allowed connections is permitted automatically. The ICMP type 3 code 4 rule matches what the OCI default security list includes; without it, large packets can silently stall.

Example 4: Find and accept the AlmaLinux 10 Marketplace image

The official AlmaLinux images on OCI are free Marketplace (partner) images, not Oracle platform images, so oci_core_images will not find them. Instead, read the listing’s default package version, then accept its terms with an agreement and a subscription. The subscription exposes the image OCID as listing_resource_id. image.tf:

# Look up the newest AlmaLinux OS 10 build in the Oracle Cloud Marketplace
data "oci_marketplace_listing" "almalinux" {
  listing_id     = var.almalinux_listing_id
  compartment_id = var.compartment_ocid
}

data "oci_marketplace_listing_package" "almalinux" {
  listing_id      = var.almalinux_listing_id
  package_version = data.oci_marketplace_listing.almalinux.default_package_version
  compartment_id  = var.compartment_ocid
}

# Accept the image terms once for this compartment and image version
resource "oci_core_app_catalog_listing_resource_version_agreement" "almalinux" {
  listing_id               = data.oci_marketplace_listing_package.almalinux.app_catalog_listing_id
  listing_resource_version = data.oci_marketplace_listing_package.almalinux.package_version
}

resource "oci_core_app_catalog_subscription" "almalinux" {
  compartment_id           = var.compartment_ocid
  listing_id               = oci_core_app_catalog_listing_resource_version_agreement.almalinux.listing_id
  listing_resource_version = oci_core_app_catalog_listing_resource_version_agreement.almalinux.listing_resource_version
  eula_link                = oci_core_app_catalog_listing_resource_version_agreement.almalinux.eula_link
  oracle_terms_of_use_link = oci_core_app_catalog_listing_resource_version_agreement.almalinux.oracle_terms_of_use_link
  signature                = oci_core_app_catalog_listing_resource_version_agreement.almalinux.signature
  time_retrieved           = oci_core_app_catalog_listing_resource_version_agreement.almalinux.time_retrieved
}

This is the Terraform equivalent of clicking Get App or accepting the terms when you pick a partner image in the Console. It is needed once per compartment and image version, and Terraform keeps track of it for you.

Example 5: An AlmaLinux 10 instance with a reserved public IP

compute.tf:

data "oci_identity_availability_domains" "ads" {
  compartment_id = var.compartment_ocid
}

locals {
  cloud_init = <<-EOT
    #cloud-config
    packages:
      - nginx
    runcmd:
      - echo "Hello from $(hostname) on $(cat /etc/almalinux-release)" > /usr/share/nginx/html/index.html
      - if systemctl is-active --quiet firewalld; then firewall-cmd --permanent --add-service=http && firewall-cmd --reload; fi
      - systemctl enable --now nginx
  EOT
}

resource "oci_core_instance" "web" {
  compartment_id      = var.compartment_ocid
  availability_domain = data.oci_identity_availability_domains.ads.availability_domains[0].name
  display_name        = "${var.name}-web"
  shape               = var.shape
  freeform_tags       = var.freeform_tags

  shape_config {
    ocpus         = var.ocpus
    memory_in_gbs = var.memory_gbs
  }

  source_details {
    source_type             = "image"
    source_id               = oci_core_app_catalog_subscription.almalinux.listing_resource_id
    boot_volume_size_in_gbs = 50
  }

  create_vnic_details {
    subnet_id        = oci_core_subnet.public.id
    assign_public_ip = false
    hostname_label   = "web1"
  }

  metadata = {
    ssh_authorized_keys = file(pathexpand(var.ssh_public_key_path))
    user_data           = base64encode(local.cloud_init)
  }

  instance_options {
    are_legacy_imds_endpoints_disabled = true
  }

  lifecycle {
    ignore_changes = [source_details[0].source_id]
  }
}

# Reserved public IP, so the address survives an instance rebuild
data "oci_core_vnic_attachments" "web" {
  compartment_id = var.compartment_ocid
  instance_id    = oci_core_instance.web.id
}

data "oci_core_private_ips" "web" {
  vnic_id = data.oci_core_vnic_attachments.web.vnic_attachments[0].vnic_id
}

resource "oci_core_public_ip" "web" {
  compartment_id = var.compartment_ocid
  lifetime       = "RESERVED"
  display_name   = "${var.name}-web-ip"
  private_ip_id  = data.oci_core_private_ips.web.private_ips[0].id
  freeform_tags  = var.freeform_tags
}

Points worth noting:

  • Flex shapes take their size from shape_config, so you can pick any OCPU and memory combination the shape allows.
  • ssh_authorized_keys installs your key for the default opc user, and user_data must be base64-encoded cloud-config. Like on other clouds, it runs on first boot only.
  • are_legacy_imds_endpoints_disabled = true turns off the old IMDSv1 metadata endpoints, so only the IMDSv2 endpoints, which require a request header, answer.
  • The ignore_changes rule stops Terraform from replacing the instance every time a newer AlmaLinux build becomes the Marketplace default. Remove it, or run terraform apply -replace=oci_core_instance.web, when you do want the new image.
  • The VNIC is created with assign_public_ip = false, and a RESERVED public IP is attached to its primary private IP instead. An ephemeral IP would change if you rebuilt the instance; a reserved one stays yours until you delete it.
  • For Ampere ARM shapes such as VM.Standard.A1.Flex, use the AlmaLinux OS 10 (AArch64) listing ocid1.mktpublisting.oc1.iad.amaaaaaac2u4zjyavtqq2zm2dezcdmxqq2uys57x6ol4ulx2lb7c2cgfoirq instead.

Example 6: A private Object Storage bucket

Every tenancy has one Object Storage namespace, which Terraform reads with a data source. NoPublicAccess keeps the bucket private and versioning keeps earlier copies of overwritten or deleted objects. storage.tf:

data "oci_objectstorage_namespace" "ns" {
  compartment_id = var.compartment_ocid
}

resource "oci_objectstorage_bucket" "artifacts" {
  compartment_id = var.compartment_ocid
  namespace      = data.oci_objectstorage_namespace.ns.namespace
  name           = "${var.name}-artifacts"
  access_type    = "NoPublicAccess"
  storage_tier   = "Standard"
  versioning     = "Enabled"
  freeform_tags  = var.freeform_tags
}

Example 7: Outputs

output "image_id" {
  value = oci_core_app_catalog_subscription.almalinux.listing_resource_id
}

output "almalinux_version" {
  value = data.oci_marketplace_listing_package.almalinux.package_version
}

output "web_public_ip" {
  value = oci_core_public_ip.web.ip_address
}

output "ssh_command" {
  value = "ssh opc@${oci_core_public_ip.web.ip_address}"
}

output "bucket" {
  value = oci_objectstorage_bucket.artifacts.name
}

output "namespace" {
  value = data.oci_objectstorage_namespace.ns.namespace
}

Step 5: Init, plan and apply

terraform init
terraform fmt -recursive
terraform validate
terraform plan -out=tfplan
terraform apply tfplan

Commit the generated .terraform.lock.hcl file so everyone uses the same provider build. A successful apply ends with output like this (OCIDs, versions and addresses are illustrative):

Apply complete! Resources: 10 added, 0 changed, 0 destroyed.

Outputs:

almalinux_version = "10.2.20261012"
bucket = "zwiegnet-lab-artifacts"
image_id = "ocid1.image.oc1..aaaaaaaa..."
namespace = "axexample1234"
ssh_command = "ssh opc@129.80.x.x"
web_public_ip = "129.80.x.x"

Step 6: Verify the deployment

# Terraform's view
terraform output
terraform state list

# OCI's view
C=$(awk -F'"' '/compartment_ocid/ {print $2}' terraform.tfvars)
oci compute instance list -c "$C" --lifecycle-state RUNNING \
  --query 'data[].{name:"display-name", shape:shape, state:"lifecycle-state"}' --output table
oci network public-ip list -c "$C" --scope REGION --lifetime RESERVED \
  --query 'data[].{name:"display-name", ip:"ip-address"}' --output table
oci os bucket get --bucket-name "$(terraform output -raw bucket)" \
  --query 'data.{access:"public-access-type", versioning:versioning}' --output table

# The instance itself (give cloud-init a couple of minutes after first boot)
ssh opc@"$(terraform output -raw web_public_ip)" 'cat /etc/almalinux-release; sudo cloud-init status --long'
curl "http://$(terraform output -raw web_public_ip)/"

You should see the instance as RUNNING, your reserved IP, NoPublicAccess and Enabled for the bucket, status: done from cloud-init, and a Hello from ... page from nginx. Run terraform plan once more; No changes means the code and OCI agree.

Example 8: Remote state in Object Storage

Since Terraform 1.12, the native oci backend stores state in an Object Storage bucket and locks it using conditional writes, so no extra lock table is needed. Create a separate, versioned bucket outside this configuration so terraform destroy never touches it:

C=$(awk -F'"' '/compartment_ocid/ {print $2}' terraform.tfvars)
NS=$(oci os ns get --query data --raw-output)
oci os bucket create --name zwiegnet-tfstate --compartment-id "$C" \
  --public-access-type NoPublicAccess --versioning Enabled
echo "$NS"

Add backend.tf. Backend blocks cannot use variables, so write the names in directly. The backend reads the same ~/.oci/config profile as the provider:

terraform {
  backend "oci" {
    bucket              = "zwiegnet-tfstate"
    namespace           = "axexample1234"
    key                 = "oci-lab/terraform.tfstate"
    region              = "us-chicago-1"
    auth                = "APIKey"
    config_file_profile = "DEFAULT"
  }
}

Move the local state into the bucket and check it arrived:

terraform init -migrate-state
oci os object list --bucket-name zwiegnet-tfstate --query 'data[].name' --output table

On Terraform versions older than 1.12, the usual workaround was the s3 backend pointed at OCI’s Amazon S3 Compatibility API. The native backend is simpler and supports locking, so prefer it.

Step 7: Destroy the lab

terraform destroy

Type yes to confirm. A bucket must be empty before OCI deletes it, and with versioning on that includes old object versions, so empty it first if you uploaded anything. The state bucket is not managed by this configuration and stays in place for the next run.

Troubleshooting

  • 401-NotAuthenticated: the API key fingerprint in ~/.oci/config does not match a key uploaded to your user, the key file path is wrong, or your clock is off. Check with oci iam region list and make sure chronyd is running.
  • 404-NotAuthorizedOrNotFound: OCI returns this both for missing resources and missing permissions. Confirm the compartment OCID and that an IAM policy such as Allow group LabAdmins to manage all-resources in compartment lab covers your user.
  • Marketplace or subscription errors: your user needs permission to manage app catalog listings in the compartment, for example Allow group LabAdmins to manage app-catalog-listing in compartment lab. Also check the listing OCID against the table on the AlmaLinux wiki.
  • Out of host capacity or LimitExceeded: the shape is full or over your service limit in that availability domain. Pick another availability domain index, another shape, or request a limit increase under Governance, Limits, Quotas and Usage.
  • BucketAlreadyExists: bucket names are unique per namespace and region. Change name or delete the old bucket.
  • SSH times out: confirm admin_cidr still matches your public IP, the route table has the 0.0.0.0/0 rule to the internet gateway, and log in as opc, not root or almalinux.
  • nginx does not answer: read /var/log/cloud-init-output.log on the instance, or open the serial console history in the Console. Check the local firewall with sudo firewall-cmd --list-services; the security list and firewalld must both allow port 80.
  • Error acquiring the state lock: another run holds the lock. When you are sure nothing else is running, use terraform force-unlock LOCK_ID.

Next steps

From here, put the instance behind an OCI Flexible Load Balancer, replace the security list with network security groups, split the network into a reusable module, and run terraform plan in CI on every pull request. That completes the four-cloud series; compare the same lab on Terraform Examples for Amazon AWS from AlmaLinux 10, Terraform Examples for Google Cloud (GCP) from AlmaLinux 10 and Terraform Examples for Microsoft Azure from AlmaLinux 10.

Leave a Reply

Your email address will not be published. Required fields are marked *