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
Create
./.oci/directory in Home directory.Login to the OCI Console.
Click your User icon → User Settings.
Go to Tokens and keys → Add API Key → Generate key pair in console.
Download the private key → save as ~/.oci/default_key.pem.
Copy and save details to
./.oci/config.
[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.pemNOTE
If there are multiple tenancies, all can be put in a single file.
[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.pemNOTE
Use Linux-style paths in key_file even on Windows.
3. Generated SSH Key pair
ssh-keygen -t rsa -b 4096 -f ~/.ssh/odbvue -N ""Keys will be saved to ~/.ssh in your home directory.
4. Install Terraform
For Windows:
winget install HashiCorp.TerraformAfter installation check that it works:
terraform -vDeployment
Step 1. Initial setup (once)
cd terraform
terraform initStep 2. Create terraform configuration files
@/provider.tf - Configures OCI provider with authentication profile and region
source
provider "oci" {
config_file_profile = var.oci_profile
region = var.region
}@/versions.tf - Specifies Terraform and provider version requirements
source
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
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
# This file is no longer used - tenancy_ocid is now configured in variables.tf@/compartment.tf - Creates or retrieves OCI compartment "odbvue-test"
source
# 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
# 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
# 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
# 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
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
################################################
# 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
# ========================================================================
# 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
# 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
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
# 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
# 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
# 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.jsonWARNING
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.
| Variable | Description | Example |
|---|---|---|
tenancy_ocid | Your OCI Tenancy OCID from API credentials | ocid1.tenancy.oc1..aaaaaaaa... |
oci_profile | OCI profile name from ~/.oci/config | DEFAULT |
region | OCI region code | eu-frankfurt-1 |
adb_admin_password | Autonomous Database admin password (9-30 chars, must include uppercase, lowercase, digit, special char) | MySecurePass123! |
adb_wallet_password | Password for ADB wallet encryption | MySecurePass123! |
ssh_public_key_path | Path to SSH public key for compute instance | ../.ssh/odbvue.pub |
adb_db_name | Autonomous Database name | odbvueadb |
adb_workload | Database workload type: OLTP, DW, AJD, or APEX | OLTP |
adb_cpu_count | Number of OCPUs for ADB (1 for Always Free) | 1 |
adb_storage_tb | Storage in TB for ADB (1 for Always Free) | 1 |
email_sender | Email Delivery approved sender (optional, leave empty to skip) | admin@odbvue.com |
email_domain | Email domain for DKIM/SPF (optional, outputs DNS records to add manually) | odbvue.com |
Step 4. Apply changes to infrastructure
terraform planterraform applyNOTE
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
terraform destroyWeb 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
# =========================
# /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
# =========================
# %%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
# 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.
./copy.sh [ssh-key] opc@[public-ip-address]
#./copy.sh ~/.ssh/odbvue opc@12.34.56.789./i13e/oci/basic/scripts/copy.sh
source
#!/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.
chmod +x ./setup.sh
./setup.sh./i13e/oci/basic/scripts/setup.sh
source
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.
chmod +x ./deploy.sh
./deploy.sh./i13e/oci/basic/scripts/deploy.sh
source
#!/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:
- 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
- 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_destroylifecycle rules
Terraform Outputs
After terraform apply, retrieve the vault secret OCID:
terraform output vault_secret_ocid
# Output: ocid1.vaultsecret.oc1.eu-stockholm-1.xxxxx