Skip to content

OCI Terraform Basic Setup ​

This setup will create and manage changes for a separate Compartment with single Autonomous Database, Public IP Address, Publicly Accessible Web server with Nginx and static site, Object storage and Email sending capabilities for OCI Free Tier.

Concepts ​

OCI - Oracle Cloud Infrastructure: a cloud platform providing compute, storage, networking, and database services.

Compartment - A logical container in OCI that organizes and isolates cloud resources. All OCI resources (compute, storage, databases) belong to a compartment. Compartments enable resource organization, access control through Identity and Access Management (IAM), and cost tracking and billing.

Infrastructure as Code - Version-controlled automation that defines cloud resources (compute instances, networks, databases) in configuration files instead of manual setup.

Terraform - An open-source IaC tool that defines resources declaratively and manages their lifecycle across cloud providers.

Topology ​

┌─ Compartment: odbvue-test ─────────────────────────────────────────────────┐
│                                                                            │
│  ┌─ VCN: 10.0.0.0/24 ──────────────────────────────────────────────────┐   │
│  │  ┌─ Public Subnet: 10.0.0.0/24 ─────────────────────────────────┐   │   │
│  │  │                                                              │   │   │
│  │  │  ┌─ Compute Instance ────────────────────────────────────┐   │   │   │
│  │  │  │  Shape: VM.Standard.E5.Flex                           │   │   │   │
│  │  │  │  OCPUs: 1, Memory: 4GB                                │   │   │   │
│  │  │  │  Image: Oracle Linux 9                                │   │   │   │
│  │  │  │  Display Name: odbvue-web                             │   │   │   │
│  │  │  │                                                       │   │   │   │
│  │  │  │  Attached VNIC (primary)                              │   │   │   │
│  │  │  │  └─ Private IP: 10.0.0.x (DHCP)                       │   │   │   │
│  │  │  │  └─ Public IP: Reserved                               │   │   │   │
│  │  │  │  └─ NSG: odbvue-nsg-web                               │   │   │   │
│  │  │  │                                                       │   │   │   │
│  │  │  │  INGRESS Rules:                                       │   │   │   │
│  │  │  │    • TCP 80 (HTTP): 0.0.0.0/0                         │   │   │   │
│  │  │  │    • TCP 443 (HTTPS): 0.0.0.0/0                       │   │   │   │
│  │  │  │    • TCP 22 (SSH): 0.0.0.0/0                          │   │   │   │
│  │  │  │                                                       │   │   │   │
│  │  │  │  EGRESS Rules:                                        │   │   │   │
│  │  │  │    • All protocols: 0.0.0.0/0 (all)                   │   │   │   │
│  │  │  └───────────────────────────────────────────────────────┘   │   │   │
│  │  │                                                              │   │   │
│  │  │  ┌─ Internet Gateway (igw) ──────────────────────────────┐   │   │   │
│  │  │  │  Routes all 0.0.0.0/0 outbound                        │   │   │   │
│  │  │  └───────────────────────────────────────────────────────┘   │   │   │
│  │  │                                                              │   │   │
│  │  └──────────────────────────────────────────────────────────────┘   │   │
│  │                                                                     │   │
│  │  Route Table: odbvue-rt                                             │   │
│  │    • Destination: 0.0.0.0/0 → IGW                                   │   │
│  │                                                                     │   │
│  │  DHCP Options: DNS-Options                                          │   │
│  │    • Domain Name Server: VcnLocalPlusInternet                       │   │
│  │    • Search Domain: vcn.oraclevcn.com                               │   │
│  │                                                                     │   │
│  └─────────────────────────────────────────────────────────────────────┘   │
│                                                                            │
│  ┌─ Autonomous Database (ADB) ─────────────────────────────────────────┐   │
│  │  Display Name: odbvue-adb                                           │   │
│  │  Workload Type: OLTP (default)                                      │   │
│  │  CPU Count: 1 (Always Free eligible)                                │   │
│  │  Storage: 1 TB (Always Free eligible)                               │   │
│  │  Backup: Automatic (7 days retention)                               │   │
│  └─────────────────────────────────────────────────────────────────────┘   │
│                                                                            │
│  ┌─ Email Delivery (Optional) ──────────────────────────────────────────┐  │
│  │  Service: Email Delivery                                             │  │
│  │  SMTP Endpoint: email-smtp.{region}.oci.oraclecloud.com:587          │  │
│  │  Email Domain: Optional domain with DKIM/SPF (var.email_domain)      │  │
│  │  Approved Sender: Optional sender address (var.email_sender)         │  │
│  └──────────────────────────────────────────────────────────────────────┘  │
│                                                                            │
│  ┌─ Object Storage ─────────────────────────────────────────────────────┐  │
│  │  Bucket: odbvue-bucket                                               │  │
│  │  Tier: Standard                                                      │  │
│  │  Versioning: Enabled (optional)                                      │  │
│  └───────────────────────────────────────────────────── ────────────────┘  │
│                                                                            │
│  ┌─ KMS Vault & Secrets ────────────────────────────────────────────────┐  │
│  │  Vault: odbvue-master-vault                                          │  │
│  │  Master Key (AES-256): odbvue-master-key                             │  │
│  │  Secret: odbvue-plsql-master-key (32-byte encryption key)            │  │
│  │  Purpose: Secure storage for DBMS_CRYPTO master key                  │  │
│  └──────────────────────────────────────────────────────────────────────┘  │
│                                                                            │
└────────────────────────────────────────────────────────────────────────────┘

WARNING

This setup allows SSH connections (port 22) from anywhere. For production environments either limit to whitelisted IP addresses or use Oracle Bastion.

Prerequisites ​

1. OCI Account ​

OCI tenant with Administration privileges

2. OCI Configuration saved locally ​

  1. Create ./.oci/ directory in Home directory.

  2. Login to the OCI Console.

  3. Click your User icon → User Settings.

  4. Go to Tokens and keys → Add API Key → Generate key pair in console.

  5. Download the private key → save as ~/.oci/default_key.pem.

  6. Copy and save details to ./.oci/config.

ini
[DEFAULT]
user=ocid1.user.oc1..aaaa1234
fingerprint=12:34:56:78:90:ab:cd:ef
tenancy=ocid1.tenancy.oc1..aaaa1234
region=us-ashburn-1
key_file=~/.oci/default_key.pem

NOTE

If there are multiple tenancies, all can be put in a single file.

ini
[TEST]
user=ocid1.user.oc1..bbbb5678
fingerprint=ab:cd:ef:12:34:56:78:90
tenancy=ocid1.tenancy.oc1..bbbb5678
region=eu-frankfurt-1
key_file=~/.oci/test_key.pem

[PROD]
user=ocid1.user.oc1..cccc9012
fingerprint=56:78:90:ab:cd:ef:12:34
tenancy=ocid1.tenancy.oc1..cccc9012
region=ap-singapore-1
key_file=~/.oci/prod_key.pem

NOTE

Use Linux-style paths in key_file even on Windows.

3. Generated SSH Key pair ​

bash
ssh-keygen -t rsa -b 4096 -f ~/.ssh/odbvue -N ""

Keys will be saved to ~/.ssh in your home directory.

4. Install Terraform ​

Install Terraform

For Windows:

powershell
winget install HashiCorp.Terraform

After installation check that it works:

bash
terraform -v

Deployment ​

Step 1. Initial setup (once) ​

bash
cd terraform
terraform init

Step 2. Create terraform configuration files ​

@/provider.tf - Configures OCI provider with authentication profile and region ​

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

@/versions.tf - Specifies Terraform and provider version requirements ​

source
tf
terraform {
  required_version = ">= 1.5.0"
  required_providers {
    oci = {
      source  = "oracle/oci"
      version = ">= 5.39.0"
    }
    local = {
      source  = "hashicorp/local"
      version = ">= 2.0"
    }
  }
}

@/variables.tf - Input variables for OCI profile, region, credentials, ADB, email, and resource names ​

source
tf
variable "oci_profile" {
  description = "OCI profile name (DEFAULT, ODBVUE, etc.)"
  type        = string
  default     = "DEFAULT"
}

variable "region" {
  description = "OCI region (e.g., eu-frankfurt-1)"
  type        = string
}

variable "tenancy_ocid" {
  description = "Your OCI Tenancy OCID"
  type        = string
}

variable "ssh_public_key_path" {
  description = "Path to .pub key"
  type        = string
  default     = "../.ssh/odbvue.pub"
}

# ADB
variable "adb_db_name" {
  type    = string
  default = "odbvueadb"
}

variable "adb_workload" {
  type    = string
  default = "OLTP"   # OLTP | DW | AJD | APEX
}

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

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

variable "adb_admin_password" {
  type      = string
  sensitive = true   # must meet OCI password policy
}

variable "adb_wallet_password" {
  description = "Password for the ADB wallet"
  type        = string
  sensitive   = true
}

# Email Delivery
variable "email_sender" {
  type    = string
  default = ""  # optional; set to enable sender creation
}

variable "email_domain" {
  description = "Email domain for DKIM/SPF configuration (e.g., example.com)"
  type        = string
  default     = ""  # optional; set to enable email domain with DKIM
}

locals {
  # Compartment and Public IP
  compartment_name            = "odbvue-test"
  public_ip_display_name      = "odbvue-web-ip"
  tenancy_ocid                = var.tenancy_ocid
  region                      = var.region
  
  # Network CIDRs and names
  vcn_cidr       = "10.0.0.0/24"
  subnet_cidr    = "10.0.0.0/24"
  vcn_name       = "odbvue-vcn"
  subnet_name    = "odbvue-public-web"
  igw_name       = "odbvue-igw"
  nsg_web_name   = "odbvue-nsg-web"
  rt_name        = "odbvue-rt"
  
  smtp_endpoint  = "email-smtp.${local.region}.oci.oraclecloud.com:587"
}

@/oci_config.tf - Deprecated (use variables.tf) ​

source
tf
# This file is no longer used - tenancy_ocid is now configured in variables.tf

@/compartment.tf - Creates or retrieves OCI compartment "odbvue-test" ​

source
tf
# Data source to find compartment by display name
data "oci_identity_compartments" "existing" {
  compartment_id = local.tenancy_ocid
  filter {
    name   = "name"
    values = [local.compartment_name]
  }
  # Only get active compartments
  filter {
    name   = "lifecycle_state"
    values = ["ACTIVE"]
  }
}

# Create compartment only if it doesn't exist
resource "oci_identity_compartment" "main" {
  # Count = 0 if found, 1 if not found
  count = length(data.oci_identity_compartments.existing.compartments) > 0 ? 0 : 1

  compartment_id = local.tenancy_ocid
  name           = local.compartment_name
  description    = "odbvue production compartment"
  enable_delete  = true
}

# Output the compartment OCID (either existing or newly created)
locals {
  # Whether a compartment with the desired name already exists (plan-time known)
  compartment_found = length(data.oci_identity_compartments.existing.compartments) > 0

  compartment_ocid = length(data.oci_identity_compartments.existing.compartments) > 0 ? data.oci_identity_compartments.existing.compartments[0].id : oci_identity_compartment.main[0].id
}

@/networking.tf - VCN, Internet Gateway, route tables, DHCP options, subnet, and NSG with firewall rules ​

source
tf
# VCN
resource "oci_core_vcn" "vcn" {
  compartment_id = local.compartment_ocid
  display_name   = local.vcn_name
  cidr_block     = local.vcn_cidr
  dns_label      = "odbvuevcn"
}

# Internet Gateway
resource "oci_core_internet_gateway" "igw" {
  compartment_id = local.compartment_ocid
  vcn_id         = oci_core_vcn.vcn.id
  display_name   = local.igw_name
  enabled        = true
}

# DHCP Options with DNS
resource "oci_core_dhcp_options" "dns_options" {
  compartment_id = local.compartment_ocid
  vcn_id         = oci_core_vcn.vcn.id
  display_name   = "DNS-Options"

  options {
    type        = "DomainNameServer"
    server_type = "VcnLocalPlusInternet"
  }

  options {
    type                = "SearchDomain"
    search_domain_names = ["vcn.oraclevcn.com"]
  }
}

# Route Table (default route to IGW)
resource "oci_core_route_table" "rt" {
  compartment_id = local.compartment_ocid
  vcn_id         = oci_core_vcn.vcn.id
  display_name   = local.rt_name

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

# Subnet (public)
resource "oci_core_subnet" "public_web" {
  compartment_id             = local.compartment_ocid
  vcn_id                     = oci_core_vcn.vcn.id
  display_name               = local.subnet_name
  cidr_block                 = local.subnet_cidr
  dns_label                  = "web"
  route_table_id             = oci_core_route_table.rt.id
  prohibit_public_ip_on_vnic = false
  dhcp_options_id            = oci_core_dhcp_options.dns_options.id
}

# NSG
resource "oci_core_network_security_group" "nsg_web" {
  compartment_id = local.compartment_ocid
  vcn_id         = oci_core_vcn.vcn.id
  display_name   = local.nsg_web_name
}

# NSG Rules  -  inbound 80/443/22
resource "oci_core_network_security_group_security_rule" "nsg_web_in_http" {
  network_security_group_id = oci_core_network_security_group.nsg_web.id
  direction                 = "INGRESS"
  protocol                  = "6"
  source                    = "0.0.0.0/0"
  description               = "Allow HTTP"
  tcp_options {
    destination_port_range {
      min = 80
      max = 80
    }
  }
}

resource "oci_core_network_security_group_security_rule" "nsg_web_in_https" {
  network_security_group_id = oci_core_network_security_group.nsg_web.id
  direction                 = "INGRESS"
  protocol                  = "6"
  source                    = "0.0.0.0/0"
  description               = "Allow HTTPS"
  tcp_options {
    destination_port_range {
      min = 443
      max = 443
    }
  }
}

resource "oci_core_network_security_group_security_rule" "nsg_web_in_ssh" {
  network_security_group_id = oci_core_network_security_group.nsg_web.id
  direction                 = "INGRESS"
  protocol                  = "6"
  source                    = "0.0.0.0/0"
  description               = "Allow SSH (per request)"
  tcp_options {
    destination_port_range {
      min = 22
      max = 22
    }
  }
}

# NSG egress  -  all
resource "oci_core_network_security_group_security_rule" "nsg_web_egress_all" {
  network_security_group_id = oci_core_network_security_group.nsg_web.id
  direction                 = "EGRESS"
  protocol                  = "all"
  destination               = "0.0.0.0/0"
  description               = "Allow all egress"
}

@/compute.tf - Oracle Linux 9 VM instance with NGINX, SSH configuration, and cloud-init setup ​

source
tf
# Get available domains in the current region
data "oci_identity_availability_domains" "available" {
  compartment_id = local.tenancy_ocid
}

# Compute constants
locals {
  instance_shape   = "VM.Standard.E2.1.Micro"
  instance_display = "odbvue-web"
}

# Find Oracle Linux 9 Minimal image (platform images are in tenancy root)
data "oci_core_images" "ol9_minimal" {
  compartment_id = local.tenancy_ocid

  filter {
    name   = "display_name"
    values = ["Oracle-Linux-9.6-Minimal-2025.06.30-0"]
  }

  state = "AVAILABLE"
}

resource "oci_core_instance" "web" {
  compartment_id      = local.compartment_ocid
  availability_domain = data.oci_identity_availability_domains.available.availability_domains[0].name
  display_name        = local.instance_display
  shape               = local.instance_shape

  source_details {
    source_type = "image"
    source_id   = data.oci_core_images.ol9_minimal.images[0].id
    # Minimum boot volume size is 50GB; set explicitly to avoid provider attempts
    # to retain a smaller inherited size (e.g., 47GB) on shape changes.
    boot_volume_size_in_gbs = 50
  }

  create_vnic_details {
    subnet_id        = oci_core_subnet.public_web.id
    nsg_ids          = [oci_core_network_security_group.nsg_web.id]
    assign_public_ip = false
    display_name     = "primary"
  }

  metadata = {
    ssh_authorized_keys = file(var.ssh_public_key_path)
  }
}


# Get the instance primary VNIC
data "oci_core_vnic_attachments" "web" {
  compartment_id = local.compartment_ocid
  instance_id    = oci_core_instance.web.id
}

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

# Resolve the private IP OCID for the VNIC's primary private IP address
data "oci_core_private_ips" "web_primary" {
  vnic_id = data.oci_core_vnic.web_primary.id
}

@/public_ip.tf - Reserves and attaches public IP to compute instance ​

source
tf
# Data source to find existing public IP by display name
# Only query when the compartment already exists; otherwise there can't be an existing IP.
data "oci_core_public_ips" "existing" {
  count          = local.compartment_found ? 1 : 0
  compartment_id = data.oci_identity_compartments.existing.compartments[0].id
  scope          = "COMPARTMENT"
  filter {
    name   = "display_name"
    values = [local.public_ip_display_name]
  }
}

# Create public IP only if it doesn't exist
resource "oci_core_public_ip" "web" {
  # Create if no existing IP in an existing compartment; also create if the compartment doesn't exist yet
  count = local.compartment_found ? (length(data.oci_core_public_ips.existing[0].public_ips) > 0 ? 0 : 1) : 1

  compartment_id = local.compartment_ocid
  lifetime       = "RESERVED"
  display_name   = local.public_ip_display_name
  # When we create the reserved IP, attach it to the instance's primary private IP
  private_ip_id  = data.oci_core_private_ips.web_primary.private_ips[0].id

  lifecycle { prevent_destroy = false }
}

# Output the public IP OCID (either existing or newly created)
locals {
  public_ip_ocid = local.compartment_found && length(data.oci_core_public_ips.existing[0].public_ips) > 0 ? data.oci_core_public_ips.existing[0].public_ips[0].id : oci_core_public_ip.web[0].id
}

@/adb.tf - Autonomous Database (Always Free), wallet generation and download ​

source
tf
resource "oci_database_autonomous_database" "adb" {
  compartment_id               = local.compartment_ocid
  db_name                      = var.adb_db_name
  display_name                 = "odbvue-adb"
  db_workload                  = var.adb_workload
  is_free_tier                 = true     # Always Free ADB
  admin_password               = var.adb_admin_password
  is_mtls_connection_required  = true
}

# Download ADB wallet
resource "oci_database_autonomous_database_wallet" "adb_wallet" {
  autonomous_database_id = oci_database_autonomous_database.adb.id
  password               = var.adb_wallet_password
  base64_encode_content  = true
}

# Save wallet to local file (using local_sensitive_file to properly handle binary data)
resource "local_sensitive_file" "adb_wallet_file" {
  content_base64 = oci_database_autonomous_database_wallet.adb_wallet.content
  filename       = "${path.module}/../.wallets/Wallet_${var.adb_db_name}.zip"

  depends_on = [oci_database_autonomous_database_wallet.adb_wallet]
}

@/identity.tf - Dynamic Group and Policy to access Object Storage from Autonomous Database ​

source
tf
################################################
# IAM: Resource principals access to Object    #
# Storage for ALL resources in the app         #
# compartment.                                 #
#                                              #
# - Dynamic groups (tenancy-level):            #
#   * Autonomous Databases in the compartment  #
# - Policies (tenancy-level, scoped to the     #
#   application compartment by name):          #
#   * manage buckets and objects for both DGs  #
################################################

# Dynamic group for Autonomous Databases in the application compartment (tenancy-level IAM)
resource "oci_identity_dynamic_group" "dg_adb" {
  compartment_id = local.tenancy_ocid
  name           = "odbvue-dg-adb"
  description    = "Autonomous Databases in ${local.compartment_name}"

  # All ADBs in the target compartment
  matching_rule = "ALL { resource.compartment.id = '${local.compartment_ocid}', resource.type = 'autonomousdatabase' }"
}

# Tenancy-level policy granting access scoped to the application compartment
resource "oci_identity_policy" "p_objectstore_compartment_manage" {
  compartment_id = local.tenancy_ocid
  name           = "odbvue-policy-objectstore-compartment-manage"
  description    = "Allow ADBs in ${local.compartment_name} to manage buckets and objects in ${local.compartment_name}"

  statements = [
    "Allow dynamic-group ${oci_identity_dynamic_group.dg_adb.name} to manage objects in compartment ${local.compartment_name}",
    "Allow dynamic-group ${oci_identity_dynamic_group.dg_adb.name} to manage buckets in compartment ${local.compartment_name}"
  ]
}

# Policy granting ADB access to read vault secrets and use KMS keys
resource "oci_identity_policy" "p_vault_read" {
  compartment_id = local.tenancy_ocid
  name           = "odbvue-policy-vault-read"
  description    = "Allow ADBs in ${local.compartment_name} to read vault secrets and use KMS keys for DBMS_CRYPTO"

  statements = [
    "Allow dynamic-group ${oci_identity_dynamic_group.dg_adb.name} to read secret-bundles in compartment ${local.compartment_name}",
    "Allow dynamic-group ${oci_identity_dynamic_group.dg_adb.name} to read vaults in compartment ${local.compartment_name}",
    "Allow dynamic-group ${oci_identity_dynamic_group.dg_adb.name} to use keys in compartment ${local.compartment_name}"
  ]
}

output "dynamic_group_adb_name" {
  value       = oci_identity_dynamic_group.dg_adb.name
  description = "Dynamic group name for Autonomous Databases"
}

output "policy_objectstore_compartment_manage_name" {
  value       = oci_identity_policy.p_objectstore_compartment_manage.name
  description = "Policy name granting manage on buckets/objects in the compartment for ADBs"
}

output "policy_vault_read_name" {
  value       = oci_identity_policy.p_vault_read.name
  description = "Policy name granting read access to vault secrets and keys for ADBs"
}

@/email.tf - Email Delivery domain and approved sender ​

source
tf
# ========================================================================
# Email Domain (for DKIM/SPF configuration)
# ========================================================================
resource "oci_email_email_domain" "domain" {
  count          = length(var.email_domain) > 0 ? 1 : 0
  compartment_id = local.compartment_ocid
  name           = var.email_domain
  description    = "Email domain for ODBVUE application emails"
  
  lifecycle { create_before_destroy = true }
}

# ========================================================================
# DKIM Configuration
# ========================================================================
resource "oci_email_dkim" "dkim" {
  count           = length(var.email_domain) > 0 ? 1 : 0
  email_domain_id = oci_email_email_domain.domain[0].id
  
  name        = "odbvue-dkim-${local.region}"
  description = "DKIM key for ${var.email_domain}"
  
  lifecycle { create_before_destroy = true }
}

# ========================================================================
# Approved Sender
# Note: Sender will be automatically linked to domain if email matches domain
# ========================================================================
resource "oci_email_sender" "sender" {
  count          = length(var.email_sender) > 0 ? 1 : 0
  compartment_id = local.compartment_ocid
  email_address  = var.email_sender
  
  lifecycle { create_before_destroy = true }
}

# ========================================================================
# Outputs
# ========================================================================
output "email_smtp_endpoint" {
  value = local.smtp_endpoint
}

output "email_domain_verification_status" {
  value       = length(var.email_domain) > 0 ? oci_email_email_domain.domain[0].state : "No email domain configured"
  description = "Email domain verification status (ACTIVE after DNS records are added)"
}

output "dns_records_required" {
  value = length(var.email_domain) > 0 ? {
    domain = var.email_domain
    
    dkim_cname_record = {
      name  = oci_email_dkim.dkim[0].dns_subdomain_name
      type  = "CNAME"
      value = oci_email_dkim.dkim[0].cname_record_value
    }
    
    spf_txt_record = {
      name  = "@"
      type  = "TXT"
      value = "v=spf1 include:${local.region}.rp.oracleemaildelivery.com ~all"
      note  = "If you have existing SPF record, add 'include:${local.region}.rp.oracleemaildelivery.com' to it"
    }
    
    instructions = <<-EOT
      
      ╔════════════════════════════════════════════════════════════════════════╗
      ║  DNS RECORDS REQUIRED FOR EMAIL DOMAIN: ${var.email_domain}
      ╚════════════════════════════════════════════════════════════════════════╝
      
      Add the following records to your DNS provider:
      
      1. DKIM CNAME Record (for email authentication):
         ────────────────────────────────────────────────────────────────────
         Name:  ${oci_email_dkim.dkim[0].dns_subdomain_name}
         Type:  CNAME
         Value: ${oci_email_dkim.dkim[0].cname_record_value}
      
      2. SPF TXT Record (for sender policy framework):
         ────────────────────────────────────────────────────────────────────
         Name:  @ (or your domain root)
         Type:  TXT
         Value: v=spf1 include:${local.region}.rp.oracleemaildelivery.com ~all
         
         ⚠️  If you already have an SPF record, ADD this to it:
             include:${local.region}.rp.oracleemaildelivery.com
             (Don't create multiple SPF records - merge into existing one)
      
      ────────────────────────────────────────────────────────────────────────
      After adding these DNS records:
      - Wait 10-30 minutes for DNS propagation
      - The domain state will change from CREATING to ACTIVE
      - DKIM state will change to ACTIVE
      - SPF will be detected automatically (is_spf = true)
      - Run 'terraform refresh' to check status
      ────────────────────────────────────────────────────────────────────────
    EOT
  } : {
    domain             = ""
    dkim_cname_record  = { name = "", type = "", value = "" }
    spf_txt_record     = { name = "", type = "", value = "", note = "" }
    instructions       = "Set var.email_domain to enable email domain configuration with DKIM/SPF"
  }
  
  description = "DNS records that must be manually added to your DNS provider for email authentication"
}

@/objectstorage.tf - Object Storage bucket with restricted access ​

source
tf
# Namespace
data "oci_objectstorage_namespace" "ns" {
  compartment_id = local.tenancy_ocid
}

resource "oci_objectstorage_bucket" "obj" {
  namespace      = data.oci_objectstorage_namespace.ns.namespace
  compartment_id = local.compartment_ocid
  name           = "odbvue-obj"
  access_type    = "NoPublicAccess"
}

@/outputs.tf - Exports instance IPs, ADB connection info, and storage hints ​

source
tf
output "instance_private_ip" {
  value = data.oci_core_vnic.web_primary.private_ip_address
}

output "instance_public_ip" {
  description = "Reserved public IP attached to the instance"
  value       = local.compartment_found && length(data.oci_core_public_ips.existing[0].public_ips) > 0 ? data.oci_core_public_ips.existing[0].public_ips[0].ip_address : oci_core_public_ip.web[0].ip_address
}

output "adb_admin_password_reminder" {
  description = "Reminder: ADB admin password was set via terraform.tfvars"
  value       = "Use 'admin' as username. Password is in your terraform.tfvars (KEEP SECURE!)"
  sensitive   = false
}

output "adb_connection_strings_note" {
  value = "See OCI Console -> Autonomous Database -> 'odbvue-adb' -> DB Connection for wallet/strings."
}

output "object_storage_bucket_url_hint" {
  value = "Use the namespace (${data.oci_objectstorage_namespace.ns.namespace}) and bucket 'odbvue-obj'. Objects are public (read)."
}

output "vault_secret_ocid" {
  description = "OCI Vault Secret OCID containing the DBMS_CRYPTO master key. Use this in PL/SQL."
  value       = oci_vault_secret.plsql_master_secret.id
  sensitive   = false
}

output "vault_master_key_ocid" {
  description = "KMS master key OCID used to encrypt the vault secret."
  value       = oci_kms_key.master_key.id
  sensitive   = false
}

@/vault.tf - OCI KMS Vault, master encryption key, and secret storage for DBMS_CRYPTO ​

source
tf
# OCI Vault & KMS Key for storing DBMS_CRYPTO master key
# This vault securely stores the 32-byte AES key used by PL/SQL encryption

data "oci_identity_compartments" "compartment" {
  compartment_id = var.tenancy_ocid
  filter {
    name   = "name"
    values = [local.compartment_name]
  }
}

locals {
  compartment_id = try(data.oci_identity_compartments.compartment.compartments[0].id, null)
}

# Create KMS Vault (if not already exists)
resource "oci_kms_vault" "master_vault" {
  compartment_id = local.compartment_id
  display_name   = "odbvue-master-vault"
  vault_type     = "DEFAULT"

  lifecycle {
    prevent_destroy = true
  }
}

# Wait for vault DNS endpoint to propagate
resource "time_sleep" "wait_for_vault_dns" {
  depends_on      = [oci_kms_vault.master_vault]
  create_duration = "45s"
}

# Create symmetric KMS master key (AES-256) for encrypting vault secrets
resource "oci_kms_key" "master_key" {
  compartment_id      = local.compartment_id
  display_name        = "odbvue-master-key"
  management_endpoint = oci_kms_vault.master_vault.management_endpoint

  key_shape {
    algorithm = "AES"
    length    = 32  # 32 bytes = 256 bits for AES-256
  }

  depends_on = [time_sleep.wait_for_vault_dns]

  lifecycle {
    prevent_destroy = true
  }
}

# Generate random 32-byte master key for DBMS_CRYPTO (AES-256)
resource "random_password" "plsql_master_key" {
  length      = 32
  min_lower   = 1
  min_upper   = 1
  min_numeric = 1
  min_special = 1

  lifecycle {
    ignore_changes = all  # Don't regenerate on every apply
  }
}

# Store the PL/SQL master key in OCI Vault Secret (encrypted with KMS key)
resource "oci_vault_secret" "plsql_master_secret" {
  compartment_id = local.compartment_id
  secret_name    = "odbvue-plsql-master-key"
  description    = "32-byte master key for DBMS_CRYPTO AES-256 encryption"

  vault_id = oci_kms_vault.master_vault.id
  key_id   = oci_kms_key.master_key.id

  secret_content {
    content_type = "BASE64"
    # Encode the random password as base64 for storage in vault
    content = base64encode(random_password.plsql_master_key.result)
    name    = "plsql-master-key-v1"
    stage   = "CURRENT"
  }

  lifecycle {
    prevent_destroy = true
  }
}

@/terraform.tfvars.example - Template showing required variables ​

source
ini
# terraform.tfvars.example
# Copy this file to terraform.tfvars and fill in your actual values
# DO NOT commit terraform.tfvars to git

# OCI Profile to use from ~/.oci/config (DEFAULT, ODBVUE, etc.)
tenancy_ocid = "ocid1.tenancy.oc1..aaaaaaaa..."
oci_profile = "DEFAULT"
region = "eu-frankfurt-1"

# Required: ADB Admin Password
# Must meet OCI password policy:
#   - 9-30 characters
#   - At least 1 uppercase, 1 lowercase, 1 digit, 1 special char (!@#$%^&*)
adb_admin_password = "MySecurePass123!"

# ADB Wallet Password
adb_wallet_password = "MySecurePass123!"

# Email Configuration
email_domain = "odbvue.com"
email_sender = "admin@odbvue.com"

@/.gitignore - Prevents secrets and terraform state from being submitted to git ​

source
ini
# OCI Configuration & Secrets
.oci/
.ssh/
.ssl/
.wallets/

# Terraform Variables Files - NEVER commit secrets!
*.tfvars
*.tfvars.local
*.auto.tfvars
*.auto.tfvars.json

# Terraform state files (CRITICAL - never commit)
*.tfstate
*.tfstate.*
.terraform.lock.hcl
.terraform/

# Terraform logs
crash.log
crash.*.log
override.tf
override.tf.json
*_override.tf
*_override.tf.json

WARNING

Make sure that .gitignore exists and prevents secrets and terraform state from being submitted to git.

Step 3. Configure terraform.tfvars ​

Copy terraform.tfvars.example to terraform.tfvars and update with your values.

VariableDescriptionExample
tenancy_ocidYour OCI Tenancy OCID from API credentialsocid1.tenancy.oc1..aaaaaaaa...
oci_profileOCI profile name from ~/.oci/configDEFAULT
regionOCI region codeeu-frankfurt-1
adb_admin_passwordAutonomous Database admin password (9-30 chars, must include uppercase, lowercase, digit, special char)MySecurePass123!
adb_wallet_passwordPassword for ADB wallet encryptionMySecurePass123!
ssh_public_key_pathPath to SSH public key for compute instance../.ssh/odbvue.pub
adb_db_nameAutonomous Database nameodbvueadb
adb_workloadDatabase workload type: OLTP, DW, AJD, or APEXOLTP
adb_cpu_countNumber of OCPUs for ADB (1 for Always Free)1
adb_storage_tbStorage in TB for ADB (1 for Always Free)1
email_senderEmail Delivery approved sender (optional, leave empty to skip)admin@odbvue.com
email_domainEmail domain for DKIM/SPF (optional, outputs DNS records to add manually)odbvue.com

Step 4. Apply changes to infrastructure ​

bash
terraform plan
bash
terraform apply

NOTE

On succesful Autonomous Database modification Wallet will be automatically downloaded to ./wallets/ folder. Make sure that the folder exists.

Step 5. (if needed) Destroy infrastructure ​

bash
terraform destroy

Web Server Setup ​

After Compute infrastructure is up and running it is necessary to set up Nginx and prepare for site deployment.

Step 1. Configuration ​

Nginx configuration template:

./i13e/oci/basic/scripts/nginx.conf.tpl ​

source
ini
# =========================
# /etc/nginx/nginx.conf
# AUTO-GENERATED - do not edit manually
# Generated from sites.yaml
# =========================
user  nginx;
worker_processes  auto;

error_log  /var/log/nginx/error.log;
pid        /run/nginx.pid;

events {
    worker_connections 1024;
}

http {
    # --- Logging ---
    log_format main '$remote_addr - $remote_user [$time_local] "$request" '
                    '$status $body_bytes_sent "$http_referer" '
                    '"$http_user_agent" "$http_x_forwarded_for"';
    access_log /var/log/nginx/access.log main;

    # --- Core ---
    sendfile on;
    tcp_nopush on;
    tcp_nodelay on;
    keepalive_timeout 65;
    types_hash_max_size 2048;
    client_max_body_size 100M;

    include /etc/nginx/mime.types;
    default_type application/octet-stream;

    # --- TLS (global) ---
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_prefer_server_ciphers on; # affects TLS1.2 only
    ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384;
    ssl_session_timeout 1d;
    ssl_session_cache   shared:MozTLS:10m;
    ssl_session_tickets off;

    # Optional OCSP stapling (uncomment and set chain path if you want this)
    # resolver 1.1.1.1 1.0.0.1 valid=300s;
    # resolver_timeout 5s;

    # --- Gzip (fixed types) ---
    gzip on;
    gzip_vary on;
    gzip_min_length 10240;
    gzip_proxied expired no-cache no-store private auth;
    gzip_disable "msie6";
    gzip_types
        text/plain
        text/css
        text/xml
        text/javascript
        application/javascript
        application/json
        application/rss+xml
        image/svg+xml;

    # =========================
    # SITES (generated from sites.yaml)
    # =========================

%%SITES_CONFIG%%

}

Nginx individual Site configuration template:

./i13e/oci/basic/scripts/nginx.site.conf.tpl ​

source
ini
    # =========================
    # %%SERVER_NAME%%
    # =========================

    # HTTP -> HTTPS (apex + IPv6)
    server {
        listen 80;
        listen [::]:80;
        server_name %%SERVER_NAME%%;
        return 301 https://$server_name$request_uri;
    }

    # Redirect www -> apex (HTTP)
    server {
        listen 80;
        listen [::]:80;
        server_name www.%%SERVER_NAME%%;
        return 301 https://%%SERVER_NAME%%$request_uri;
    }

    # Redirect www -> apex (HTTPS)
    server {
        listen 443 ssl http2;
        listen [::]:443 ssl http2;
        server_name www.%%SERVER_NAME%%;

        ssl_certificate     /etc/ssl/certs/odbvue.crt;
        ssl_certificate_key /etc/ssl/private/odbvue.key;

        return 301 https://%%SERVER_NAME%%$request_uri;
    }

    # Main server block (Blue/Green ready)
    server {
        listen 443 ssl http2;
        listen [::]:443 ssl http2;
        server_name %%SERVER_NAME%%;

        ssl_certificate     /etc/ssl/certs/odbvue.crt;
        ssl_certificate_key /etc/ssl/private/odbvue.key;

        # Blue/Green deployment: symlink points to active version (blue or green)
        root  /var/www/%%SITE_NAME%%/current;
        index index.html index.htm;

        # ---- Security headers (HTTPS only) ----
        add_header X-Frame-Options "DENY" always;
        add_header X-Content-Type-Options "nosniff" always;
        add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
        add_header Referrer-Policy "strict-origin-when-cross-origin" always;
        add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
        add_header Cross-Origin-Opener-Policy "same-origin" always;
        add_header Cross-Origin-Resource-Policy "same-site" always;

        server_tokens off;

        # Allow ACME challenges if needed
        location ^~ /.well-known/acme-challenge/ { allow all; }

        # Fonts (long cache + CORS)
        location ~* \.(woff2?|ttf|eot|otf)$ {
            add_header Cache-Control "public, max-age=31536000, immutable" always;
            add_header Access-Control-Allow-Origin "*" always;
            try_files $uri =404;
        }

        # JS & CSS (long cache)
        location ~* \.(?:js|css)$ {
            add_header Cache-Control "public, max-age=31536000, immutable" always;
            add_header X-Content-Type-Options "nosniff" always;
            try_files $uri =404;
        }

        # Images (long cache)
        location ~* \.(?:png|jpe?g|gif|ico|svg)$ {
            add_header Cache-Control "public, max-age=31536000, immutable" always;
            try_files $uri =404;
        }

        # HTML – never cache (explicit index + any .html)
        location = /index.html {
            add_header Cache-Control "no-store, must-revalidate" always;
            try_files $uri =404;
        }
        location ~* \.html?$ {
            add_header Cache-Control "no-store, must-revalidate" always;
            try_files $uri =404;
        }

        # SPA fallback
        location / {
            try_files $uri $uri/ /index.html;
        }

        # Deny dotfiles & VCS, except .well-known
        location ~ /\.(?!well-known/) { deny all; }
        location ~* /\.(git|hg|svn|env|htaccess) { deny all; }
    }

Sites:

./i13e/oci/basic/scripts/sites.yaml ​

source
yaml
# Sites
sites:
  - siteName: "main"
    domainName: "odbvue.com"
    remotePath: "/var/www/main/"
    localPath: "~/odbvue/main"

  - siteName: "apps"
    domainName: "apps.odbvue.com"
    remotePath: "/var/www/apps/"
    localPath: "~/odbvue/apps/dist"

  - siteName: "wiki"
    domainName: "wiki.odbvue.com"
    remotePath: "/var/www/wiki/"
    localPath: "~/odbvue/apps/.vitepress/dist"

Step 2. SSL Certificates ​

Assure that Wildcard SSL certificates are in ./scripts/.sll/ folder.

Step 3. Copy all needed files to remote server ​

Script will copy all configuration files, scripts and site content to web server's ~\deploy\ folder.

bash
./copy.sh [ssh-key] opc@[public-ip-address]
#./copy.sh ~/.ssh/odbvue opc@12.34.56.789

./i13e/oci/basic/scripts/copy.sh ​

source
sh
#!/bin/bash
# copy.sh - Copy site files to remote directory
# Usage: bash copy.sh <ssh-key> <remote-host> [remoteDir]
# Example: bash copy.sh ~/.ssh/odbvue opc@79.72.16.185 ~/deploy

set -euo pipefail

SSH_KEY="${1:-}"
REMOTE_HOST="${2:-}"
REMOTE_DIR="${3:-~/deploy}"

if [[ -z "$SSH_KEY" || -z "$REMOTE_HOST" ]]; then
    echo "Usage: bash copy.sh <ssh-key> <remote-host> [remoteDir]"
    echo "Example: bash copy.sh ~/.ssh/odbvue opc@79.72.16.185 ~/deploy"
    exit 1
fi

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SITES_YAML_PATH="$SCRIPT_DIR/sites.yaml"

# Expand SSH key path
SSH_KEY="${SSH_KEY/#\~/$HOME}"

# Parse sites.yaml and collect siteName and localPath
declare -a SITES
declare -A LOCAL_PATHS

current_site=""
while IFS= read -r line; do
    if [[ $line =~ ^[[:space:]]*-[[:space:]]*siteName:[[:space:]]*\"([^\"]+)\" ]]; then
        current_site="${BASH_REMATCH[1]}"
        SITES+=("$current_site")
    elif [[ -n "$current_site" && $line =~ ^[[:space:]]*localPath:[[:space:]]*\"([^\"]+)\" ]]; then
        LOCAL_PATHS["$current_site"]="${BASH_REMATCH[1]}"
    fi
done < "$SITES_YAML_PATH"

# Create remote directory first
echo "Creating remote $REMOTE_DIR directory..."
ssh -i "$SSH_KEY" "$REMOTE_HOST" "mkdir -p $REMOTE_DIR"

# Copy all script files to remote (including .ssl folder)
echo "Copying script files..."
scp -q -i "$SSH_KEY" -r "$SCRIPT_DIR"/* "$REMOTE_HOST:$REMOTE_DIR/"
scp -q -i "$SSH_KEY" -r "$SCRIPT_DIR/.ssl" "$REMOTE_HOST:$REMOTE_DIR/.ssl"
echo "  Script files copied"

# Copy each site
for site in "${SITES[@]}"; do
    local_path="${LOCAL_PATHS[$site]}"
    
    # Expand ~ to home directory
    local_path="${local_path/#\~/$HOME}"
    
    # Resolve relative paths
    if [[ $local_path == ../* ]]; then
        local_path="$(cd "$SCRIPT_DIR" && cd "$local_path" && pwd)"
    fi
    
    echo "Copying $site..."
    
    # scp -r /path/to/files user@host:$REMOTE_DIR/siteName (quiet mode)
    scp -q -i "$SSH_KEY" -r "$local_path" "$REMOTE_HOST:$REMOTE_DIR/$site"
    echo "  Copied"
done

echo "Done!"

Step 4. Run setup ​

Script will setup Nginx based on configuration templates and will create Helo, {site-name}! sites according to ./sites.yaml.

bash
chmod +x ./setup.sh
./setup.sh

./i13e/oci/basic/scripts/setup.sh ​

source
sh
set -euo pipefail

# ============================================================================
# COLORS & LOGGING
# ============================================================================

RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'

log_info() {
    printf "%bℹ%b %s\n" "$BLUE" "$NC" "$1"
}

log_success() {
    printf "%b✓%b %s\n" "$GREEN" "$NC" "$1"
}

log_warn() {
    printf "%b⚠%b %s\n" "$YELLOW" "$NC" "$1"
}

log_error() {
    printf "%b✗%b %s\n" "$RED" "$NC" "$1"
}

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SITES="{}"

check_local_files() {
if [ ! -f "$SCRIPT_DIR/sites.yaml" ]; then
    log_error "sites.yaml not found next to script ($SCRIPT_DIR)"
    exit 1
fi
if [ ! -f "$SCRIPT_DIR/nginx.conf.tpl" ]; then
    log_error "nginx.conf.tpl not found next to script ($SCRIPT_DIR)"
    exit 1
fi
if [ ! -f "$SCRIPT_DIR/nginx.site.conf.tpl" ]; then
    log_error "nginx.site.conf.tpl not found next to script ($SCRIPT_DIR)"
    exit 1
fi
if [[ ! -d "$SCRIPT_DIR/.ssl" ]]; then
    log_error "SSL directory not found at $SCRIPT_DIR/.ssl"
    exit 1
fi
}

get_sites() {
    # Parse sites.yaml using pure bash (no Python/PyYAML needed)
    local yaml_file="$SCRIPT_DIR/sites.yaml"
    local site_name=""
    local domain_name=""
    SITES=""
    
    while IFS= read -r line; do
        # Extract siteName
        if [[ "$line" =~ siteName:[[:space:]]*\"?([^\"]+)\"? ]]; then
            site_name="${BASH_REMATCH[1]}"
        fi
        # Extract domainName
        if [[ "$line" =~ domainName:[[:space:]]*\"?([^\"]+)\"? ]]; then
            domain_name="${BASH_REMATCH[1]}"
        fi
        # When we have both, add to SITES and reset
        if [[ -n "$site_name" && -n "$domain_name" ]]; then
            [[ -n "$SITES" ]] && SITES+=$'\n'
            SITES+="${site_name}|${domain_name}"
            site_name=""
            domain_name=""
        fi
    done < "$yaml_file"
}

remove_existing_directories() {
     while IFS='|' read -r siteName domainName; do
        if [ -n "$siteName" ]; then
            local base_dir="/var/www/$siteName"

            log_warn "Removing site directory: $base_dir"
            sudo rm -rf "$base_dir"
        fi
    done <<< "$SITES"
}

create_new_directories() {
    while IFS='|' read -r siteName domainName; do
        if [ -n "$siteName" ]; then
            local base_dir="/var/www/$siteName"

            sudo mkdir -p "$base_dir"
            sudo chmod 755 "$base_dir"

            for slot in blue green; do
                sudo mkdir -p "$base_dir/$slot"
                sudo chown nginx:nginx "$base_dir/$slot"
                sudo chmod 755 "$base_dir/$slot"
                log_success "Created $base_dir/$slot"
            done

            sudo chown nginx:nginx "$base_dir"
            sudo chmod 755 "$base_dir"

            if [[ ! -L "$base_dir/current" ]]; then
                sudo ln -s "$base_dir/blue" "$base_dir/current"
                sudo chown -h nginx:nginx "$base_dir/current"
                log_success "Created symlink: $base_dir/current -> blue"
            else
                # Ensure symlink owner is nginx (important for nginx to follow it)
                sudo chown -h nginx:nginx "$base_dir/current" 2>/dev/null || true
            fi
        fi
    done <<< "$SITES"

    
    sudo chown nginx:nginx /var/www
    sudo chmod 755 /var/www
    
    if command -v restorecon &> /dev/null; then
        log_info "Restoring SELinux contexts..."
        sudo restorecon -Rv /var/www/ 2>/dev/null || true
        log_success "SELinux contexts restored"
    fi
}

copy_ssl_certs() {
    log_info "Setting up SSL certificates..."
    
    # Find first .crt and .txt files in .ssl directory
    local cert_file=$(find "$SCRIPT_DIR/.ssl" -maxdepth 1 -name "*.crt" -type f | head -1)
    local key_file=$(find "$SCRIPT_DIR/.ssl" -maxdepth 1 -name "*.txt" -type f | head -1)
    
    if [[ -z "$cert_file" ]]; then
        log_error "No .crt certificate file found in $SCRIPT_DIR/.ssl"
        exit 1
    fi
    
    if [[ -z "$key_file" ]]; then
        log_error "No .txt private key file found in $SCRIPT_DIR/.ssl"
        exit 1
    fi
    
    log_info "Found certificate: $cert_file"
    log_info "Found private key: $key_file"
    
    log_warn "Removing existing SSL directories..."
    sudo rm -rf /etc/ssl/certs /etc/ssl/private
    
    log_info "Creating SSL directories..."
    sudo mkdir -p /etc/ssl/certs /etc/ssl/private
    
    log_info "Copying SSL certificates..."
    sudo cp "$cert_file" /etc/ssl/certs/odbvue.crt
    sudo cp "$key_file" /etc/ssl/private/odbvue.key

    log_info "Setting certificate permissions..."
    sudo chmod 644 /etc/ssl/certs/odbvue.crt
    sudo chmod 600 /etc/ssl/private/odbvue.key
    
    log_success "SSL certificates configured"
}

generate_nginx_config() {
    log_info "Generating nginx.conf..."

    local nginx_conf_tpl="$SCRIPT_DIR/nginx.conf.tpl"
    local nginx_site_conf_tpl="$SCRIPT_DIR/nginx.site.conf.tpl"

    local sites_config=""
    while IFS='|' read -r siteName domainName; do
        local site_conf=$(cat "$nginx_site_conf_tpl")
        site_conf="${site_conf//%%SERVER_NAME%%/$domainName}"
        site_conf="${site_conf//%%SITE_NAME%%/$siteName}"
        sites_config+="$site_conf"$'\n'
    done <<< "$SITES"

    local temp_conf=$(mktemp)
    local temp_sites=$(mktemp)
    
    echo "$sites_config" > "$temp_sites"
    sed "/%%SITES_CONFIG%%/{
        r $temp_sites
        d
    }" "$nginx_conf_tpl" > "$temp_conf"
    rm "$temp_sites"
    
    if ! sudo nginx -t -c "$temp_conf" 2>/dev/null; then
        log_error "nginx configuration validation failed"
        rm "$temp_conf"
        return 1
    fi
    
    sudo cp "$temp_conf" /etc/nginx/nginx.conf
    sudo chown root:root /etc/nginx/nginx.conf
    sudo chmod 644 /etc/nginx/nginx.conf
    
    rm "$temp_conf"
    log_success "Generated: /etc/nginx/nginx.conf"

}

validate_config() {
    log_info "Validating nginx configuration..."
    
    if sudo nginx -t; then
        log_success "nginx configuration is valid"
    else
        log_error "nginx configuration validation failed"
        return 1
    fi
}

deploy_sample_site() {
    log_info "Deploying sample site to each site's blue slot..."
    
    while IFS='|' read -r siteName domainName; do
        local blue_dir="/var/www/$siteName/blue"
        
        if [ -d "$blue_dir" ]; then
            echo "<html><head><title>Welcome to $siteName</title></head><body><h1>Welcome, $siteName!</h1></body></html>" | sudo tee "$blue_dir/index.html" >/dev/null
            sudo chown nginx:nginx "$blue_dir/index.html"
            sudo chmod 644 "$blue_dir/index.html"
            log_success "Deployed sample site to $blue_dir"
        else
            log_error "Blue directory not found for site $siteName at $blue_dir"
        fi
    done <<< "$SITES"
}

reload_nginx() {
    if systemctl is-active --quiet nginx; then
        log_info "Reloading nginx..."
        if sudo systemctl reload nginx; then
            log_success "nginx reloaded successfully"
        else
            log_error "Failed to reload nginx"
            return 1
        fi
    else
        log_info "Starting nginx..."
        if sudo systemctl enable --now nginx; then
            log_success "nginx started and enabled"
        else
            log_error "Failed to start nginx"
            return 1
        fi
    fi
}

ensure_swap() {
    local min_swap_mb=4000
    local swap_size_mb=$(free -m | awk '/^Swap:/ {print $2}')

    if [[ "$swap_size_mb" -ge "$min_swap_mb" ]]; then
        log_success "Swap already ${swap_size_mb}MB, sufficient"
        return 0
    fi

    log_info "Swap is ${swap_size_mb}MB, need at least ${min_swap_mb}MB. Creating 4GB swap..."
    
    # Disable existing swap if present
    sudo swapoff /swapfile 2>/dev/null || true
    sudo rm -f /swapfile
    
    # Create new 4GB swap
    sudo dd if=/dev/zero of=/swapfile bs=128M count=32 status=progress
    sudo chmod 600 /swapfile
    sudo mkswap /swapfile
    sudo swapon /swapfile
    
    # Add to fstab if not already there
    grep -q '/swapfile' /etc/fstab || echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab >/dev/null
    
    log_success "4GB swap created and enabled"
}

tune_memory() {
    log_info "Tuning memory settings..."
    sudo sysctl -q vm.swappiness=80
    sudo sysctl -q vm.vfs_cache_pressure=50
}

install_packages() {
    local packages=("nginx" "rsync")
    local to_install=()

    for pkg in "${packages[@]}"; do
        if ! rpm -q "$pkg" &>/dev/null; then
            to_install+=("$pkg")
        fi
    done

    if [[ ${#to_install[@]} -eq 0 ]]; then
        log_success "All packages already installed (nginx, rsync)"
        return 0
    fi

    log_info "Installing ${to_install[*]} (this may take a few minutes)..."
    sudo dnf install -y --setopt=install_weak_deps=False --setopt=keepcache=0 "${to_install[@]}" || {
        log_error "Failed to install packages. Check network/dnf configuration."
        exit 1
    }
    log_success "Packages installed: ${to_install[*]}"
}

configure_firewall() {
    log_info "Configuring firewall..."
    
    # Start firewalld if not running
    sudo systemctl start firewalld 2>/dev/null || true
    sudo systemctl enable firewalld 2>/dev/null || true
    
    # Open HTTP and HTTPS ports
    sudo firewall-cmd --permanent --add-service=http
    sudo firewall-cmd --permanent --add-service=https
    sudo firewall-cmd --reload
    
    log_success "Firewall configured (HTTP/HTTPS open)"
}

main() {
    log_info "Setting up Web server..."

    ensure_swap
    tune_memory
    install_packages
    configure_firewall

    check_local_files
    get_sites
    remove_existing_directories
    create_new_directories
    copy_ssl_certs
    generate_nginx_config
    validate_config
    deploy_sample_site
    reload_nginx
}

main "$@"

Step 5. Deploy content (optional) ​

Script will deploy actual content based on ./sites.yaml.

bash
chmod +x ./deploy.sh
./deploy.sh

./i13e/oci/basic/scripts/deploy.sh ​

source
sh
#!/bin/bash
# bash deploy.sh # Deploy all sites

set -euo pipefail

RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'

log_info() {
    printf "%bℹ%b %s\n" "$BLUE" "$NC" "$1"
}

log_success() {
    printf "%b✓%b %s\n" "$GREEN" "$NC" "$1"
}

log_warn() {
    printf "%b⚠%b %s\n" "$YELLOW" "$NC" "$1"
}

log_error() {
    printf "%b✗%b %s\n" "$RED" "$NC" "$1"
}

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"

check_local_files() {
    if [ ! -f "$SCRIPT_DIR/sites.yaml" ]; then
        log_error "sites.yaml not found in $SCRIPT_DIR"
        exit 1
    fi
}

check_deps() {
    if ! command -v python3 &>/dev/null; then
        log_error "python3 is not installed"
        exit 1
    fi
    
    if ! command -v nginx &>/dev/null; then
        log_error "nginx is not installed"
        exit 1
    fi
}

parse_sites() {
    python3 - "$SCRIPT_DIR/sites.yaml" << 'PYTHON_EOF'
import sys, yaml
with open(sys.argv[1]) as f:
    config = yaml.safe_load(f)
for site in config.get('sites', []):
    print(f"{site['siteName']}|{site['remotePath']}")
PYTHON_EOF
}

get_active_slot() {
    local site=$1
    local current_link="/var/www/$site/current"
    
    if [[ ! -L "$current_link" ]]; then
        log_warn "Symlink not found for $site, assuming 'blue' is active"
        echo "blue"
        return
    fi
    
    local target=$(readlink "$current_link")
    
    if [[ "$target" == *"/blue" ]]; then
        echo "blue"
    elif [[ "$target" == *"/green" ]]; then
        echo "green"
    else
        log_warn "Unknown symlink target for $site: $target, assuming 'blue'"
        echo "blue"
    fi
}

get_inactive_slot() {
    local active=$1
    if [[ "$active" == "blue" ]]; then
        echo "green"
    else
        echo "blue"
    fi
}

deploy_site() {
    local siteName=$1
    local remotePath=$2
    
    log_info "=== Deploying $siteName ==="
    
    # Resolve relative paths from script directory
    local localPath=$SCRIPT_DIR/$siteName/
    
    # Check if source exists
    if [[ ! -d "$localPath" ]]; then
        log_error "Local path does not exist: $localPath"
        return 1
    fi
    
    if [[ -z "$(ls -A "$localPath" 2>/dev/null)" ]]; then
        log_error "Local path is empty: $localPath"
        return 1
    fi
    
    # Determine active and inactive slots
    local active_slot=$(get_active_slot "$siteName")
    local inactive_slot=$(get_inactive_slot "$active_slot")
    
    log_info "  Current active slot: $active_slot"
    log_info "  Deploying to slot: $inactive_slot"
    log_info "  Source: $localPath"
    log_info "  Target base: $remotePath"
    
    local target_dir="$remotePath$inactive_slot"
    
    # Clear target slot
    log_info "Clearing target directory..."
    sudo rm -rf "$target_dir"/*
    
    # Copy files
    log_info "Copying files..."
    if ! sudo cp -r "$localPath"/* "$target_dir/" 2>/dev/null; then
        log_error "Failed to copy files to $target_dir"
        return 1
    fi
    
    # Set permissions
    log_info "Setting permissions..."
    sudo find "$target_dir" -type d -exec chmod 755 {} \;
    sudo find "$target_dir" -type f -exec chmod 644 {} \;
    sudo chown -R nginx:nginx "$target_dir"
    
    # Handle SELinux if present
    if command -v restorecon &> /dev/null; then
        sudo restorecon -Rv "$target_dir" 2>/dev/null || true
    fi
    
    # Flip symlink (atomic swap)
    log_info "Flipping symlink..."
    local current_link="$remotePath/current"
    local new_target="$remotePath$inactive_slot"
    local temp_link="${current_link}.tmp"
    
    sudo ln -sf "$new_target" "$temp_link"
    sudo mv -T "$temp_link" "$current_link"
    sudo chown -h nginx:nginx "$current_link"
    
    log_success "Deployed $siteName to $inactive_slot"
    echo ""
}

reload_nginx() {
    log_info "Validating nginx configuration..."
    
    if ! sudo nginx -t 2>&1 | grep -q "successful"; then
        log_error "nginx validation failed"
        return 1
    fi
    
    log_info "Reloading nginx..."
    if sudo systemctl reload nginx; then
        log_success "nginx reloaded successfully"
    else
        log_error "Failed to reload nginx"
        return 1
    fi
}

main() {
    log_info "Starting deployment..."
    echo ""
    
    check_local_files
    check_deps
    
    while IFS='|' read -r siteName remotePath; do
        deploy_site "$siteName" "$remotePath" || true
    done < <(parse_sites)

    reload_nginx

    log_info "Deployment completed."
    echo ""
}

main "$@"

Email sending setup ​

DNS Records ​

Terraform will output records that need to be added to DNS manually. Example:

  1. DKIM CNAME Record (for email authentication):
  • Name: odbvue-dkim-eu-stockholm-1._domainkey.odbvue.com.

  • Type: CNAME

  • Value: odbvue-dkim-eu-stockholm-1.odbvue.com.dkim.arn1.oracleemaildelivery.com

  1. SPF TXT Record (for sender policy framework):
  • Name: @ (or your domain root)

  • Type: TXT

  • Value: v=spf1 include:eu-stockholm-1.rp.oracleemaildelivery.com ~all

NOTE

If you already have an SPF record, ADD this to it: include:eu-stockholm-1.rp.oracleemaildelivery.com (Don't create multiple SPF records - merge into existing one)

After adding these DNS records:

  • Wait 10-30 minutes for DNS propagation

  • The domain state will change from CREATING to ACTIVE

  • DKIM state will change to ACTIVE

  • SPF will be detected automatically (is_spf = true)

  • Run 'terraform refresh' to check status

Vault & Secrets Management ​

Overview ​

The infrastructure includes an OCI KMS Vault that securely stores the master encryption key used by PL/SQL's DBMS_CRYPTO for encrypting sensitive application settings.

Architecture:

  • KMS Vault (odbvue-master-vault) - Secured container for cryptographic keys
  • Master Key (odbvue-master-key) - AES-256 symmetric key used to encrypt vault secrets
  • Secret (odbvue-plsql-master-secret) - 32-byte base64-encoded encryption key for DBMS_CRYPTO

Security Features ​

  • Key Encryption - The 32-byte master key is encrypted by OCI KMS before storage
  • Audit Logging - All vault access is logged to OCI Cloud Audit
  • Resource Principal - ADB authenticates using IAM resource principal (no credentials in code)
  • Lifecycle Protection - Resources have prevent_destroy lifecycle rules

Terraform Outputs ​

After terraform apply, retrieve the vault secret OCID:

bash
terraform output vault_secret_ocid
# Output: ocid1.vaultsecret.oc1.eu-stockholm-1.xxxxx