Skip to content

Commit e5d7cc1

Browse files
committed
feat(infra): add oracle cloud terraform stack
container instances (api/web/worker), database with postgresql, oci cache for redis, oci queue x3 with built-in dlq, object storage, flexible load balancer with routing policies + waf rate limiting, github oidc via identity propagation trust (api-key fallback documented). built against oci provider ~> 8.0 with native oci state backend (terraform >= 1.12). gaps vs other stacks documented in readme: no container autoscaling, no first-party cdn, no cron-to-queue scheduler.
1 parent c717408 commit e5d7cc1

25 files changed

Lines changed: 1995 additions & 14 deletions

apps/infra/README.md

Lines changed: 22 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -9,28 +9,29 @@ Terraform configurations for provisioning the fullstack-starter stack. Each clou
99
| [`gcp/`](./gcp/) | GCP — Cloud Run, Cloud SQL, Memorystore, Cloud Tasks, Pub/Sub, GCS, Cloud CDN | Serverless-first, scale-to-zero workloads |
1010
| [`aws/`](./aws/) | AWS — ECS Fargate, Aurora PostgreSQL, ElastiCache, SQS/SNS, S3, CloudFront, SES | Container-first, AWS-native shops |
1111
| [`az/`](./az/) | Azure — Container Apps, PostgreSQL Flexible Server, Azure Cache for Redis, Service Bus, Blob Storage, Front Door | Azure-native shops, KEDA-based scaling |
12+
| [`oci/`](./oci/) | Oracle Cloud — Container Instances, Database with PostgreSQL, OCI Cache, OCI Queue, Object Storage, LB + WAF | Generous free tier, cost-sensitive deployments |
1213
| [`freemium/`](./freemium/) | Vercel + Supabase + Backblaze B2 | Zero-cost prototypes and side projects |
1314

1415
### Service mapping
1516

16-
| Component | `gcp/` | `aws/` | `az/` |
17-
|-----------|--------|--------|-------|
18-
| API / Web / Worker | Cloud Run | ECS Fargate | Container Apps |
19-
| Container registry | Artifact Registry | ECR | ACR |
20-
| Database (PostgreSQL 16) | Cloud SQL | Aurora | PostgreSQL Flexible Server |
21-
| Cache (Redis 7) | Memorystore | ElastiCache | Azure Cache for Redis |
22-
| Task queues | Cloud Tasks (default / high / low priority) | SQS (default / high-priority / low-priority + DLQ) | Service Bus subscriptions (SQL filter + DLQ) |
23-
| Event bus | Pub/Sub | SNS → SQS fan-out | Service Bus topic fan-out |
24-
| Scheduled jobs | Cloud Scheduler → Pub/Sub (`schedules` var) | EventBridge Scheduler → SNS (`schedules` var) | Container Apps Jobs cron (`schedules` var) |
25-
| Uploads / static assets | GCS + Cloud CDN | S3 + CloudFront (OAC) | Blob Storage + Front Door |
26-
| WAF / rate limiting | Cloud Armor | WAFv2 (rate limit + AWS managed rules) | Front Door WAF (rate limit; managed rules need Premium) |
27-
| Email || SES (opt-in via `ses_domain`) ||
28-
| CI/CD auth | Workload Identity Federation | GitHub Actions OIDC role | Entra ID federated credential |
17+
| Component | `gcp/` | `aws/` | `az/` | `oci/` |
18+
|-----------|--------|--------|-------|--------|
19+
| API / Web / Worker | Cloud Run | ECS Fargate | Container Apps | Container Instances (fixed counts) |
20+
| Container registry | Artifact Registry | ECR | ACR | OCIR |
21+
| Database (PostgreSQL 16) | Cloud SQL | Aurora | PostgreSQL Flexible Server | Database with PostgreSQL |
22+
| Cache (Redis 7) | Memorystore | ElastiCache | Azure Cache for Redis | OCI Cache |
23+
| Task queues | Cloud Tasks (default / high / low priority) | SQS (default / high-priority / low-priority + DLQ) | Service Bus subscriptions (SQL filter + DLQ) | OCI Queue ×3 (built-in DLQ) |
24+
| Event bus | Pub/Sub | SNS → SQS fan-out | Service Bus topic fan-out | — (direct queue produce) |
25+
| Scheduled jobs | Cloud Scheduler → Pub/Sub (`schedules` var) | EventBridge Scheduler → SNS (`schedules` var) | Container Apps Jobs cron (`schedules` var) | — (no native cron→queue; see oci/scheduler.tf) |
26+
| Uploads / static assets | GCS + Cloud CDN | S3 + CloudFront (OAC) | Blob Storage + Front Door | Object Storage (no first-party CDN) |
27+
| WAF / rate limiting | Cloud Armor | WAFv2 (rate limit + AWS managed rules) | Front Door WAF (rate limit; managed rules need Premium) | OCI WAF on LB (rate limit) |
28+
| Email || SES (opt-in via `ses_domain`) |||
29+
| CI/CD auth | Workload Identity Federation | GitHub Actions OIDC role | Entra ID federated credential | Identity Propagation Trust (GitHub OIDC) |
2930

3031
## Shared conventions
3132

3233
- **Secrets via Infisical.** Every `plan`/`apply` task is wrapped with `infisical run --env=<env> --path=/infra`, which injects secrets as `TF_VAR_*` environment variables (`DATABASE_PASSWORD`, `JWT_SECRET`, `BETTER_AUTH_SECRET`, OAuth and AI keys). Nothing sensitive is committed; `*.tfvars` is gitignored.
33-
- **Remote state.** GCS backend for `gcp/`, S3 + DynamoDB locking for `aws/`, Azure Storage (`azurerm` backend) for `az/`. All are configured at `terraform init` time via `-backend-config`.
34+
- **Remote state.** GCS backend for `gcp/`, S3 + DynamoDB locking for `aws/`, Azure Storage (`azurerm` backend) for `az/`, native `oci` backend for `oci/` (requires Terraform >= 1.12). All are configured at `terraform init` time via `-backend-config`.
3435
- **Naming.** Resources are prefixed with `${app_name}-${environment}` (default `fullstack-starter-dev`).
3536
- **Environment sizing.** `terraform.tfvars` for dev, `terraform.prod.tfvars` for production. Copy from the `.example` files in each stack.
3637
- **API availability.** The API service keeps a minimum of 1 instance/task in every stack (no cold starts).
@@ -62,6 +63,13 @@ mise //apps/infra/az:apply
6263
mise //apps/infra/az:plan:prod
6364
mise //apps/infra/az:apply:prod
6465

66+
# Oracle Cloud
67+
mise //apps/infra/oci:init
68+
mise //apps/infra/oci:plan
69+
mise //apps/infra/oci:apply
70+
mise //apps/infra/oci:plan:prod
71+
mise //apps/infra/oci:apply:prod
72+
6573
# Formatting / validation (either stack)
6674
mise //apps/infra/gcp:fmt
6775
mise //apps/infra/aws:validate

apps/infra/oci/.gitignore

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# Terraform
2+
*.tfstate
3+
*.tfstate.*
4+
*.tfstate.backup
5+
.terraform/
6+
.terraform.lock.hcl
7+
8+
# Sensitive files
9+
*.tfvars
10+
!*.tfvars.example
11+
12+
# Crash logs
13+
crash.log
14+
crash.*.log
15+
16+
# Override files
17+
override.tf
18+
override.tf.json
19+
*_override.tf
20+
*_override.tf.json
21+
22+
# CLI config
23+
.terraformrc
24+
terraform.rc

apps/infra/oci/README.md

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
# OCI Infrastructure
2+
3+
Terraform configuration for deploying the fullstack-starter stack to Oracle Cloud Infrastructure. This is the OCI counterpart of the GCP configuration in [`../gcp/`](../gcp/), the AWS configuration in [`../aws/`](../aws/), and the Azure configuration in [`../az/`](../az/).
4+
5+
## Architecture
6+
7+
| Component | OCI Service | GCP (`../gcp/`) | AWS (`../aws/`) | Azure (`../az/`) |
8+
|-----------|-------------|-----------------|-----------------|------------------|
9+
| Isolation boundary | Child compartment under `parent_compartment_ocid` | Project | Account + tags | Resource group |
10+
| API / Web / Worker | Container Instances (fixed counts, no autoscaling) | Cloud Run | ECS Fargate | Container Apps |
11+
| Container registry | OCIR (`oci_artifacts_container_repository`) | Artifact Registry | ECR | ACR |
12+
| Load balancing | Flexible LB + routing policies (host/path) | Global LB + Cloud Run | ALB | Container Apps ingress |
13+
| Database | OCI Database with PostgreSQL 16 | Cloud SQL | Aurora PostgreSQL 16 | PostgreSQL Flexible Server |
14+
| Cache | OCI Cache (Redis 7, TLS-only) | Memorystore | ElastiCache Redis 7 | Azure Cache for Redis |
15+
| Queues | OCI Queue ×3 (built-in DLQ, delivery count 5) | Cloud Tasks | SQS + DLQ | Service Bus subscriptions |
16+
| Scheduled jobs | — (documented gap, `schedules` is a no-op) | Cloud Scheduler → Pub/Sub | EventBridge Scheduler → SNS | Container Apps Jobs cron |
17+
| Uploads storage | Object Storage `uploads` bucket | GCS | S3 | Storage container |
18+
| Static assets / CDN | Object Storage `static` bucket (no first-party CDN) | GCS + Cloud CDN | S3 + CloudFront | Front Door |
19+
| WAF | WAF policy on LB (rate limit ~1000 req/min/IP) | Cloud Armor | WAFv2 on ALB | Front Door WAF |
20+
| CI/CD auth | Identity propagation trust (GitHub OIDC → UPST) | Workload Identity Federation | GitHub OIDC role | Entra ID federated credential |
21+
| Runtime auth | Dynamic group + resource principal | Service accounts | ECS task role | Managed identity |
22+
| Monitoring | ONS topic + Monitoring alarms (LB 5xx, PG CPU) || CloudWatch + SNS | Monitor + action group |
23+
24+
## Prerequisites
25+
26+
- Terraform >= 1.12.0 (the native `backend "oci"` requires it — newer than the >= 1.9.0 floor of the other stacks)
27+
- OCI credentials with admin access in the parent compartment plus identity-domain administration for the OIDC trust (`~/.oci/config` or explicit `user_ocid`/`fingerprint`/`private_key_path` vars)
28+
- An Object Storage bucket for remote state (see `versions.tf`)
29+
- An OCI Certificates service certificate in the same region when using a custom domain (`certificate_ocid`)
30+
31+
## Usage
32+
33+
```bash
34+
cd apps/infra/oci
35+
36+
# Initialize with your state backend (native OCI backend, Terraform >= 1.12)
37+
terraform init \
38+
-backend-config="bucket=your-tfstate-bucket" \
39+
-backend-config="namespace=your-objectstorage-namespace" \
40+
-backend-config="key=oci/terraform.tfstate" \
41+
-backend-config="region=ap-seoul-1" \
42+
-backend-config="config_file_profile=DEFAULT"
43+
44+
# Copy and edit variables
45+
cp terraform.tfvars.example terraform.tfvars
46+
47+
# Plan / apply (secrets injected via Infisical)
48+
mise //apps/infra/oci:plan
49+
mise //apps/infra/oci:apply
50+
51+
# Production
52+
cp terraform.prod.tfvars.example terraform.prod.tfvars
53+
mise //apps/infra/oci:plan:prod
54+
mise //apps/infra/oci:apply:prod
55+
```
56+
57+
## Secrets
58+
59+
All sensitive variables (`DATABASE_PASSWORD`, `JWT_SECRET`, `BETTER_AUTH_SECRET`, OAuth keys, AI API keys) are injected as `TF_VAR_*` environment variables via `infisical run`, same as the GCP/AWS/Azure setups — Infisical is the single source of truth. The DB password is set as the PostgreSQL admin password and wired into the container instance environments.
60+
61+
## CI/CD (GitHub Actions OIDC)
62+
63+
OCI supports keyless OIDC federation via identity-domain **identity propagation trust** (GA since 2025): the GitHub Actions JWT is exchanged for a short-lived User Principal Session Token (UPST) impersonating a non-interactive service user. Set `identity_domain_ocid` (usually the Default domain OCID) and Terraform provisions the service user, group, deploy policy, confidential token-exchange client, and the trust pinned to `repo:<github_repository>:ref:refs/heads/main`.
64+
65+
<!-- oma-docs:ignore-start -->
66+
In the workflow, exchange the GitHub OIDC token at the `github_actions_token_endpoint` output using the `github_actions_client_id` output (e.g. with `gtrevorrow/oci-token-exchange-action`), then use the UPST for `oci` CLI / OCIR docker login (`BEARER_TOKEN` username).
67+
<!-- oma-docs:ignore-end -->
68+
69+
If `identity_domain_ocid` is empty no CI identity is provisioned — fall back to a dedicated IAM user with an API key stored as GitHub secrets (deviation from the repo's "OIDC first" rule, only for tenancies where you lack identity-domain admin rights).
70+
71+
## Routing
72+
73+
<!-- oma-docs:ignore-start -->
74+
- With `domain` + `certificate_ocid`: HTTPS listener forwards to Web by default; a routing policy sends `api.<domain>` to API; the HTTP listener 301-redirects to HTTPS via a rule set.
75+
- Without a certificate: a single HTTP listener forwards to Web, with `/api/v1*`, `/health`, `/docs`, `/openapi.json` routed to API by a routing policy.
76+
<!-- oma-docs:ignore-end -->
77+
78+
## Notes
79+
80+
Honest gaps versus the other stacks, found while verifying against 2026 docs:
81+
82+
- **No autoscaling**: OCI Container Instances have no scaling rules, no rolling deploys, and no image-drift updates. Capacity is `api_count` / `web_count` / `worker_count` (fixed). Deploys restart or recreate instances so `:latest` is re-pulled. If you outgrow this, the OCI-native answer is OKE (Kubernetes), not Container Instances.
83+
- **No first-party CDN**: OCI has no CloudFront/Cloud CDN/Front Door equivalent. The `static` bucket ships private — serve via pre-authenticated requests (see `storage.tf`) or put a third-party CDN (e.g. Cloudflare) in front.
84+
- **No cron→queue scheduler**: Resource Scheduler only does start/stop lifecycle actions and parameterless Functions triggers; there is no EventBridge/Cloud Scheduler equivalent that publishes a payload to a queue. The `schedules` variable is a validated no-op; `scheduler.tf` documents two workarounds.
85+
- **State backend**: Oracle deprecated the S3-compatible Object Storage backend; this stack uses the native `backend "oci"` (Terraform >= 1.12), which is why the `required_version` floor is higher than the other stacks.
86+
- **OIDC**: supported (see CI/CD above) but needs identity-domain admin rights and a one-time `identity_domain_ocid` input; policy statements reference plain group names, which resolve in the **Default** identity domain — prefix `'<domain>'/'<group>'` in `iam.tf` if you use a non-default domain.
87+
- **Database name**: `oci_psql_db_system` has no database-name argument — create the `app` database once after provisioning (or via migrations); see the comment in `postgres.tf`. Read scaling guidance also lives there.
88+
- **Runtime identity**: container instances authenticate to Queue/Object Storage/OCIR through a dynamic group + resource principal (`iam.tf`) — no static keys in the containers beyond app-level secrets.
89+
- **OCIR**: repositories are created explicitly so they land in the stack compartment with tags (implicit creation on push would put them in the tenancy root). Image retention (ECR "keep last 10" equivalent) is a console/CLI-level OCIR policy, not Terraform.
90+
- **OCI Cache is TLS-only** — clients must connect with TLS (`REDIS_TLS=true`).
91+
- **Free tier**: OCI's Always Free tier covers the 10 Mbps flexible LB and (separately) generous A1 Arm compute for plain VMs; Container Instances, PostgreSQL, and OCI Cache are paid. Dev defaults are sized minimal; `CI.Standard.A1.Flex` halves container cost if you build arm64 images.
92+
- **Logs**: Container Instances expose container logs via the API/console only; there is no managed log-export integration like CloudWatch Logs to wire up in Terraform yet.

apps/infra/oci/compartment.tf

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# Child compartment for the whole stack (mirrors az/main.tf resource group)
2+
resource "oci_identity_compartment" "main" {
3+
compartment_id = var.parent_compartment_ocid
4+
name = local.name_prefix
5+
description = "${var.app_name} ${var.environment} stack"
6+
7+
# Allow `terraform destroy` to delete the compartment outside prod
8+
enable_delete = var.environment != "prod"
9+
10+
freeform_tags = local.common_tags
11+
}
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
# Container Instances (mirror aws/ecs.tf services).
2+
#
3+
# OCI Container Instances have NO autoscaling and NO rolling deploys — each
4+
# instance is a fixed-size unit. Scale by raising api_count / web_count /
5+
# worker_count; deploy new images by restarting (image tag "latest" is
6+
# re-pulled on restart) or recreating instances. See README Notes.
7+
8+
locals {
9+
availability_domain = data.oci_identity_availability_domains.main.availability_domains[0].name
10+
11+
# Shared backend environment (api + worker), mirrors aws/ecs.tf
12+
# backend_environment (OCI env vars are a map, not a name/value list)
13+
backend_environment = {
14+
ENVIRONMENT = var.environment
15+
OCI_REGION = var.region
16+
DATABASE_HOST = oci_psql_db_system.main.network_details[0].primary_db_endpoint_private_ip
17+
# Read scaling guide: add when read replicas are enabled (see postgres.tf)
18+
# DATABASE_READ_HOST = <reader endpoint>
19+
DATABASE_NAME = var.db_name
20+
DATABASE_USER = var.db_user
21+
DATABASE_PASSWORD = var.DATABASE_PASSWORD
22+
REDIS_HOST = oci_redis_redis_cluster.main.primary_fqdn
23+
REDIS_PORT = "6379"
24+
REDIS_TLS = "true"
25+
STORAGE_BUCKET = oci_objectstorage_bucket.uploads.name
26+
STORAGE_NAMESPACE = local.namespace
27+
QUEUE_IDS = jsonencode({
28+
for name in local.queue_names : name => oci_queue_queue.main[name].id
29+
})
30+
QUEUE_MESSAGES_ENDPOINT = oci_queue_queue.main["default"].messages_endpoint
31+
}
32+
}
33+
34+
# API
35+
resource "oci_container_instances_container_instance" "api" {
36+
count = var.api_count
37+
38+
compartment_id = oci_identity_compartment.main.id
39+
availability_domain = local.availability_domain
40+
display_name = "${local.name_prefix}-api-${count.index}"
41+
42+
shape = var.container_shape
43+
shape_config {
44+
ocpus = var.api_ocpus
45+
memory_in_gbs = var.api_memory_in_gbs
46+
}
47+
48+
container_restart_policy = "ALWAYS"
49+
50+
containers {
51+
display_name = "api"
52+
image_url = local.api_image
53+
54+
environment_variables = merge(local.backend_environment, {
55+
JWT_SECRET = var.JWT_SECRET
56+
API_URL = local.api_url
57+
})
58+
59+
health_checks {
60+
health_check_type = "HTTP"
61+
port = 8000
62+
path = "/health"
63+
interval_in_seconds = 30
64+
timeout_in_seconds = 5
65+
failure_threshold = 3
66+
initial_delay_in_seconds = 60
67+
failure_action = "KILL"
68+
}
69+
}
70+
71+
vnics {
72+
subnet_id = oci_core_subnet.private.id
73+
nsg_ids = [oci_core_network_security_group.apps.id]
74+
is_public_ip_assigned = false
75+
}
76+
77+
freeform_tags = local.common_tags
78+
}
79+
80+
# Web
81+
resource "oci_container_instances_container_instance" "web" {
82+
count = var.web_count
83+
84+
compartment_id = oci_identity_compartment.main.id
85+
availability_domain = local.availability_domain
86+
display_name = "${local.name_prefix}-web-${count.index}"
87+
88+
shape = var.container_shape
89+
shape_config {
90+
ocpus = var.web_ocpus
91+
memory_in_gbs = var.web_memory_in_gbs
92+
}
93+
94+
container_restart_policy = "ALWAYS"
95+
96+
containers {
97+
display_name = "web"
98+
image_url = local.web_image
99+
100+
environment_variables = {
101+
ENVIRONMENT = var.environment
102+
NEXT_PUBLIC_API_URL = local.api_url
103+
BETTER_AUTH_SECRET = var.BETTER_AUTH_SECRET
104+
BETTER_AUTH_URL = local.web_url
105+
GOOGLE_CLIENT_ID = var.GOOGLE_CLIENT_ID
106+
GOOGLE_CLIENT_SECRET = var.GOOGLE_CLIENT_SECRET
107+
GITHUB_CLIENT_ID = var.GITHUB_CLIENT_ID
108+
GITHUB_CLIENT_SECRET = var.GITHUB_CLIENT_SECRET
109+
KAKAO_CLIENT_ID = var.KAKAO_CLIENT_ID
110+
KAKAO_CLIENT_SECRET = var.KAKAO_CLIENT_SECRET
111+
}
112+
113+
health_checks {
114+
health_check_type = "HTTP"
115+
port = 3000
116+
path = "/api/health"
117+
interval_in_seconds = 30
118+
timeout_in_seconds = 5
119+
failure_threshold = 3
120+
initial_delay_in_seconds = 60
121+
failure_action = "KILL"
122+
}
123+
}
124+
125+
vnics {
126+
subnet_id = oci_core_subnet.private.id
127+
nsg_ids = [oci_core_network_security_group.apps.id]
128+
is_public_ip_assigned = false
129+
}
130+
131+
freeform_tags = local.common_tags
132+
}
133+
134+
# Worker (no ingress — consumes queues)
135+
resource "oci_container_instances_container_instance" "worker" {
136+
count = var.worker_count
137+
138+
compartment_id = oci_identity_compartment.main.id
139+
availability_domain = local.availability_domain
140+
display_name = "${local.name_prefix}-worker-${count.index}"
141+
142+
shape = var.container_shape
143+
shape_config {
144+
ocpus = var.worker_ocpus
145+
memory_in_gbs = var.worker_memory_in_gbs
146+
}
147+
148+
container_restart_policy = "ALWAYS"
149+
150+
containers {
151+
display_name = "worker"
152+
image_url = local.worker_image
153+
154+
environment_variables = merge(local.backend_environment, {
155+
OPENAI_API_KEY = var.OPENAI_API_KEY
156+
ANTHROPIC_API_KEY = var.ANTHROPIC_API_KEY
157+
GOOGLE_AI_API_KEY = var.GOOGLE_AI_API_KEY
158+
})
159+
}
160+
161+
vnics {
162+
subnet_id = oci_core_subnet.private.id
163+
nsg_ids = [oci_core_network_security_group.apps.id]
164+
is_public_ip_assigned = false
165+
}
166+
167+
freeform_tags = local.common_tags
168+
}
169+
170+
# The container instance resource exports only vnic_id — resolve private IPs
171+
# for the LB backends through the VNIC data source.
172+
data "oci_core_vnic" "api" {
173+
count = var.api_count
174+
vnic_id = oci_container_instances_container_instance.api[count.index].vnics[0].vnic_id
175+
}
176+
177+
data "oci_core_vnic" "web" {
178+
count = var.web_count
179+
vnic_id = oci_container_instances_container_instance.web[count.index].vnics[0].vnic_id
180+
}

0 commit comments

Comments
 (0)