I have seen many teams deploy OCI Functions and call it done. The function works, the test passes, and then they realize there is no authentication, no rate limiting, no proper routing, and no way to version the API. The function URL is just floating there, exposed.
OCI API Gateway is what turns a collection of serverless functions into an actual production API. In this post I will walk through building a complete serverless API stack from scratch using OCI API Gateway, OCI Functions, and Terraform. Everything here is production-oriented: proper IAM, CORS, authentication via JWT, rate limiting, and a deployment pipeline.
Architecture Overview
The stack we are building looks like this:
Client Request → OCI API Gateway → Route Policy (auth + rate limit) → OCI Function → Response
The API Gateway sits in a public subnet and handles all the cross-cutting concerns: TLS termination, JWT validation, CORS headers, and usage plans. The Functions sit in a private subnet with no public exposure. The Gateway invokes them over OCI’s internal network.
Prerequisites
You need the following before starting:
- OCI CLI configured with a valid profile
- Terraform 1.5 or later
- Docker installed locally (for building function images)
- An OCI tenancy with permissions to manage API Gateway, Functions, IAM, and Networking
Setting Up the Network
Functions should never run in a public subnet. We need a VCN with a public subnet for the Gateway and a private subnet for Functions.
resource "oci_core_vcn" "api_vcn" { compartment_id = var.compartment_id cidr_block = "10.0.0.0/16" display_name = "api-gateway-vcn" dns_label = "apivcn"}resource "oci_core_subnet" "public_subnet" { compartment_id = var.compartment_id vcn_id = oci_core_vcn.api_vcn.id cidr_block = "10.0.1.0/24" display_name = "gateway-public-subnet" dns_label = "gatewaypub" route_table_id = oci_core_route_table.public_rt.id security_list_ids = [oci_core_security_list.gateway_sl.id]}resource "oci_core_subnet" "private_subnet" { compartment_id = var.compartment_id vcn_id = oci_core_vcn.api_vcn.id cidr_block = "10.0.2.0/24" display_name = "functions-private-subnet" dns_label = "funcpriv" prohibit_public_ip_on_vnic = true route_table_id = oci_core_route_table.private_rt.id security_list_ids = [oci_core_security_list.functions_sl.id]}
The private subnet has prohibit_public_ip_on_vnic = true. This is not optional — it ensures no Function instance can accidentally get a public IP assigned.
For the private subnet to reach OCI services (like Container Registry to pull images), add a Service Gateway:
resource "oci_core_service_gateway" "sgw" { compartment_id = var.compartment_id vcn_id = oci_core_vcn.api_vcn.id display_name = "functions-service-gateway" services { service_id = data.oci_core_services.all_services.services[0].id }}resource "oci_core_route_table" "private_rt" { compartment_id = var.compartment_id vcn_id = oci_core_vcn.api_vcn.id display_name = "private-route-table" route_rules { network_entity_id = oci_core_service_gateway.sgw.id destination = "all-iad-services-in-oracle-services-network" destination_type = "SERVICE_CIDR_BLOCK" }}
IAM: Dynamic Groups and Policies
OCI Functions need explicit permission to be invoked by API Gateway. This is done through Dynamic Groups and policies not hardcoded credentials.
resource "oci_identity_dynamic_group" "api_gateway_dg" { compartment_id = var.tenancy_ocid name = "api-gateway-dynamic-group" description = "Allows API Gateway to invoke Functions" matching_rule = "ALL {resource.type = 'ApiGateway', resource.compartment.id = '${var.compartment_id}'}"}resource "oci_identity_policy" "gateway_invoke_policy" { compartment_id = var.compartment_id name = "gateway-invoke-functions-policy" description = "Grants API Gateway permission to invoke Functions" statements = [ "Allow dynamic-group api-gateway-dynamic-group to use functions-family in compartment id ${var.compartment_id}" ]}
Without this policy, the Gateway will return a 500 when it tries to invoke your function, and the error message is not always obvious about the cause.
Building and Pushing the Function
We will write a simple order validation function in Python. Create this directory structure:
order-validator/
├── func.py
├── func.yaml
└── requirements.txt
schema_version: 20180708name: order-validatorversion: 0.0.1runtime: python3.11build_image: fnproject/python:3.11-devrun_image: fnproject/python:3.11entrypoint: /python/bin/fdk /function/func.py handlermemory: 256
requirements.txt:
fdk>=0.1.57
func.py:
import ioimport jsonimport loggingfrom fdk import responsedef handler(ctx, data: io.BytesIO = None): logger = logging.getLogger() try: body = json.loads(data.getvalue()) except (Exception, ValueError) as ex: logger.error("Failed to parse request body: " + str(ex)) return response.Response( ctx, status_code=400, response_data=json.dumps({"error": "Invalid JSON in request body"}), headers={"Content-Type": "application/json"} ) required_fields = ["order_id", "customer_id", "items", "total_amount"] missing = [f for f in required_fields if f not in body] if missing: return response.Response( ctx, status_code=422, response_data=json.dumps({ "error": "Missing required fields", "fields": missing }), headers={"Content-Type": "application/json"} ) if not isinstance(body.get("items"), list) or len(body["items"]) == 0: return response.Response( ctx, status_code=422, response_data=json.dumps({"error": "Order must contain at least one item"}), headers={"Content-Type": "application/json"} ) if body["total_amount"] <= 0: return response.Response( ctx, status_code=422, response_data=json.dumps({"error": "total_amount must be greater than zero"}), headers={"Content-Type": "application/json"} ) return response.Response( ctx, status_code=200, response_data=json.dumps({ "status": "valid", "order_id": body["order_id"], "item_count": len(body["items"]), "validated_at": ctx.RequestID() }), headers={"Content-Type": "application/json"} )
Build and push the function image to OCI Container Registry:
# Log in to OCI Container Registrydocker login <region-key>.ocir.io -u '<tenancy-namespace>/<username>'# Build the function imagefn build --verbose# Tag and pushdocker tag order-validator:0.0.1 <region-key>.ocir.io/<tenancy-namespace>/functions/order-validator:0.0.1docker push <region-key>.ocir.io/<tenancy-namespace>/functions/order-validator:0.0.1
Deploying the Function with Terraform
resource "oci_functions_application" "orders_app" { compartment_id = var.compartment_id display_name = "orders-api" subnet_ids = [oci_core_subnet.private_subnet.id] config = { LOG_LEVEL = "INFO" ENV = "production" } trace_config { is_enabled = true domain_id = oci_apm_apm_domain.tracing.id }}resource "oci_functions_function" "order_validator" { application_id = oci_functions_application.orders_app.id display_name = "order-validator" image = "<region-key>.ocir.io/<tenancy-namespace>/functions/order-validator:0.0.1" memory_in_mbs = 256 timeout_in_seconds = 30 provisioned_concurrency_config { strategy = "CONSTANT" count = 2 }}
The provisioned_concurrency_config block with count = 2 keeps two warm instances running at all times. This eliminates cold starts for your two most frequent concurrent requests — critical for an API that needs consistent latency.
Creating the API Gateway and Deployment
This is where everything comes together. The Gateway deployment defines your routes, authentication, and rate limiting in a single resource:
resource "oci_apigateway_gateway" "orders_gateway" { compartment_id = var.compartment_id display_name = "orders-api-gateway" endpoint_type = "PUBLIC" subnet_id = oci_core_subnet.public_subnet.id certificate_id = var.tls_certificate_ocid}resource "oci_apigateway_deployment" "orders_deployment" { compartment_id = var.compartment_id display_name = "orders-api-v1" gateway_id = oci_apigateway_gateway.orders_gateway.id path_prefix = "/v1" specification { request_policies { authentication { type = "JWT_AUTHENTICATION" token_header = "Authorization" token_auth_scheme = "Bearer" is_anonymous_access_allowed = false public_keys { type = "REMOTE_JWKS" uri = "https://your-identity-provider.com/.well-known/jwks.json" max_cache_duration_in_hours = 1 } verify_claims { key = "iss" values = ["https://your-identity-provider.com"] is_required = true } verify_claims { key = "aud" values = ["orders-api"] is_required = true } } rate_limiting { rate_in_requests_per_second = 100 rate_key = "CLIENT_IP" } cors { allowed_origins = ["https://yourdomain.com"] allowed_methods = ["GET", "POST", "OPTIONS"] allowed_headers = ["Authorization", "Content-Type"] max_age_in_seconds = 3600 is_allow_credentials_enabled = true } } routes { path = "/orders/validate" methods = ["POST"] backend { type = "ORACLE_FUNCTIONS_BACKEND" function_id = oci_functions_function.order_validator.id } request_policies { body_validation { required = true content { media_type = "application/json" validation_type = "NONE" } } } response_policies { header_transformations { set_headers { items { name = "X-Request-ID" values = ["${request.headers[x-request-id]}"] } items { name = "Strict-Transport-Security" values = ["max-age=31536000; includeSubDomains"] } } } } } routes { path = "/orders/validate" methods = ["OPTIONS"] backend { type = "STOCK_RESPONSE_BACKEND" status = 204 headers { name = "Access-Control-Allow-Origin" value = "https://yourdomain.com" } } } }}
A few things worth highlighting in this configuration.
The JWT authentication block uses REMOTE_JWKS, which means the Gateway fetches your identity provider’s public keys and caches them for one hour. It validates the signature, the issuer, and the audience on every request before your Function ever sees the traffic. Your function code does not need to do any token validation at all.
The rate_limiting block uses CLIENT_IP as the rate key, which applies the 100 req/sec limit per caller rather than globally across all callers. Switch this to TOTAL if you want a single shared limit for the entire API.
The OPTIONS route returns a 204 with no backend function invoked. This handles preflight CORS requests without consuming Function compute time.
Testing the Deployment
Once Terraform applies successfully, get your Gateway endpoint:
terraform output gateway_endpoint
Test without a token (should get 401):
curl -X POST https://<gateway-id>.apigateway.<region>.oci.customer-oci.com/v1/orders/validate \ -H "Content-Type: application/json" \ -d '{"order_id": "ORD-001"}'
Test with a valid JWT:
TOKEN=$(curl -s -X POST https://your-idp.com/oauth/token \ -d "grant_type=client_credentials&client_id=...&client_secret=..." | jq -r .access_token)curl -X POST https://<gateway-id>.apigateway.<region>.oci.customer-oci.com/v1/orders/validate \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "order_id": "ORD-001", "customer_id": "CUST-999", "items": [{"sku": "ITEM-A", "qty": 2}], "total_amount": 49.99 }'
Expected response:
{ "status": "valid", "order_id": "ORD-001", "item_count": 1, "validated_at": "ocid1.apigateway.request..."}
Enabling Execution Logs
Without logs, debugging a Gateway issue is nearly impossible. Enable execution logs on both the Gateway and the Function application:
resource "oci_logging_log" "gateway_execution_log" { display_name = "gateway-execution-log" log_group_id = oci_logging_log_group.api_logs.id log_type = "SERVICE" configuration { source { category = "execution" resource = oci_apigateway_deployment.orders_deployment.id service = "apigateway" source_type = "OCISERVICE" } compartment_id = var.compartment_id } retention_duration = 30 is_enabled = true}
Gateway execution logs include the full request path, JWT claim values, matched route, backend invocation time, and response status for every request. These logs are the first place to look when a request is failing.
Final Thoughts
The combination of OCI API Gateway and OCI Functions gives you a production API stack with almost no infrastructure to manage. The Gateway handles authentication, rate limiting, CORS, and TLS. The Functions handle business logic. Terraform manages the entire configuration as code, so every change is reviewed, versioned, and repeatable.
The pieces that trip most people up are the Dynamic Group IAM policy (the Gateway cannot invoke Functions without it), provisioned concurrency (without it your p99 latency will be terrible on cold paths), and execution logging (without it you are debugging blind).
Get those three right and the rest follows naturally.
Regards, Osama