OCI Functions started accepting a ZIP on 24 September 2026. Go, Java, Node.js and Python functions can now ship as an archive instead of an image pushed to OCI Container Registry. In the same release window terraform-provider-oci v9.3.0 added a source_details block to oci_functions_function and deprecated the top-level image and image_digest arguments. A comment in the provider source says the new block stays optional “for a year”.
The archive is the easy part. The only Python runtime that code-only functions accept, python312.ol9, already has a published deprecation date of 30 April 2027. The question to settle before the first deploy is the one the image used to answer for you: who owns the runtime, and when does it change?
What actually moved
With an image, the runtime was whatever you baked in, including the Function Development Kit (FDK). A code-only function runs on a managed runtime that Oracle patches and versions: OS updates, language patches, FDK updates, security fixes. You own the code, the dependencies, and proof that they still work on the version you land on.
| Image-based function | Code-only function | |
|---|---|---|
| Deployment artifact | Container image in OCIR | ZIP archive, or an uber JAR for Java |
| Who patches the OS and language runtime | You, by rebuilding the image | OCI Functions, by publishing runtime versions |
| Dependencies | Installed at image build | Vendored into the archive. Nothing is downloaded at deploy time |
| Size limit | Container image limits | 25 MB uploaded directly, 250 MB from Object Storage |
| Custom OS packages, own base image, image signing | Yes | No |
| Switching model later | Create a new function | Create a new function |
Code-only functions run in OC1 regions on x86, Arm and multi-architecture applications. Ruby and C# are not supported.
The archive contract
Validation checks the archive against both the runtime and the application’s shape. Python and Node.js code goes under a root-level function/ directory, and the Python handler is written as <file>.<function>. Go ships a statically linked binary named func. Python dependencies go under python/, because OCI Functions installs nothing for you.
# bash
# Python code-only archive. Dependencies are vendored: OCI Functions installs nothing.
set -euo pipefail
rm -rf build && mkdir -p build/archive/function build/archive/python
cp src/func.py build/archive/function/
# Built on a Mac or an Arm laptop? Ask pip for Linux wheels matching the application shape.
python3 -m pip install -r requirements.txt -t build/archive/python \
--platform manylinux2014_x86_64 --only-binary=:all: --python-version 3.12
(cd build/archive && zip -qr ../function.zip function python)
# handler: func.handler runtime: python312.ol9
One trap sits in python/. The Python FDK ships inside the managed runtime, and if your requirements pull in a different FDK version, the packaged copy overrides Oracle’s. A stale pip freeze can pin you to an FDK nobody chose.
Go gets no language runtime at all. Code-only Go functions run on the plain Oracle Linux 9 runtime, ol9, and you pick the Go version when you compile. A multi-architecture application needs one binary per architecture:
# bash
# Go, multi-architecture application: one static binary per architecture.
set -euo pipefail
mkdir -p dist/fn-arch-x86 dist/fn-arch-arm
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o dist/fn-arch-x86/func .
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o dist/fn-arch-arm/func .
(cd dist && zip -qr ../function.zip fn-arch-x86 fn-arch-arm)
CGO_ENABLED=0 is our addition to Oracle’s example. It keeps the binary static whichever host builds it.
The Terraform shape
DIRECT_ARCHIVE takes the file as base64 in archive_file, and the provider deliberately keeps those bytes in state (the function is called preserveDirectArchiveFileFromState) without marking them sensitive. A 25 MB archive becomes about 33 MB of base64 in every state file and saved plan. We use OBJECT_STORAGE_ARCHIVE, with CI uploading the archive under the commit it was built from:
# bash
# CI: one immutable object per build, never overwritten.
oci os object put --bucket-name fn-artifacts --no-overwrite \
--name "functions/report-fn/${GITHUB_SHA}.zip" --file build/function.zip
Terraform then only references the key:
# hcl
resource "oci_functions_function" "this" {
application_id = var.application_id
display_name = var.function_name
memory_in_mbs = "256"
timeout_in_seconds = 60
source_details {
source_type = "ARCHIVE"
handler = "func.handler"
archive_source_details {
archive_source_type = "OBJECT_STORAGE_ARCHIVE"
namespace = var.os_namespace
bucket = var.artifacts_bucket
# One key per build: every deploy is a visible change to `object`.
object = "functions/${var.function_name}/${var.build_sha}.zip"
}
runtime_config {
runtime_config_type = "MANUAL"
functions_runtime_name = var.runtime_name
functions_runtime_version_id = local.runtime_version_id
}
}
}
The per-build key matters because object_version_id is optional, and without it the function runs the latest version of the object. Overwrite the same key from CI and the running code changes with nothing in the diff. A key per build makes each deploy a reviewed change, and since Terraform does not own the objects, old archives stay in the bucket for rollback until a lifecycle rule clears them. We key on the commit, not a hash of the ZIP, because zip records timestamps and the same source would hash differently on every build.
The function’s resource principal also needs read access to the bucket. Oracle’s example scopes the policy to a single object name. We scope it to the bucket, since the key changes with every build:
# OCI IAM policy
Allow any-user to read objects in compartment fn-artifacts where all {request.principal.type = 'fnapp', request.principal.compartment.id = '<functions-compartment-ocid>', target.bucket.name = 'fn-artifacts'}
Two runtime modes, and no third
runtime_config_type accepts FUNCTION_UPDATE or MANUAL. Fully automatic runtime upgrades are not supported.
FUNCTION_UPDATE moves the function to the newest compatible runtime version whenever you update it, and at no other time. For Python, compatible means patch releases of the same minor version; for Java and Node.js, minor and patch releases. So your next one-line fix also ships a runtime upgrade, and since the configuration never names the version, no reviewer sees it. If the fix breaks, you cannot tell the code from the runtime, and rolling back the archive is just another update that keeps the new runtime. Oracle’s documented rollback is to switch to MANUAL and select the previous version.
MANUAL pins functions_runtime_version_id, so a runtime change becomes a diff someone approves. In exchange you own the calendar:
| Before deprecation | Between deprecation and decommission | After decommission | |
|---|---|---|---|
| New function creates or deployments | Allowed | Not allowed | Not allowed |
| Updates to existing functions | Allowed | Allowed | Only to move to a supported runtime |
| Invocations of existing functions | Allowed | Allowed | Allowed |
| Support and patches | Full, including security fixes | None | None |
Oracle plans decommission for six months after deprecation. Read the middle column with an infrastructure-as-code eye. A pinned production function keeps serving traffic after deprecation, unpatched. The same pin in a new environment fails outright, because a fresh terragrunt apply tries to create a function on a runtime that no longer accepts new ones. The pin that keeps production stable is the pin that stops you standing up the next environment.
Neither mode crosses a minor version for Python. Moving from python312.ol9 to whatever succeeds it is a change to functions_runtime_name that someone has to make, test and ship before 30 April 2027.
Code-only does not remove the runtime upgrade. It decides whether anyone sees it happen.
What we set by default
MANUAL everywhere. Dev follows the current runtime version, so a new release shows up there as a plan diff. Prod pins the version OCID dev ran on, promoted like an image tag. Inside the module:
# hcl
variable "runtime_name" {
type = string # python312.ol9, node24.ol9, java21.ol9, or ol9 for Go
}
variable "runtime_version_id" {
type = string
default = null # null = follow the current version
}
data "oci_functions_functions_runtimes" "this" {
name = var.runtime_name
}
locals {
runtime = data.oci_functions_functions_runtimes.this.functions_runtime_collection[0].items[0]
runtime_version_id = coalesce(var.runtime_version_id, local.runtime.current_functions_runtime_version_id)
# v9.3.0 renders this as "2027-04-30 00:00:00 +0000 UTC", not RFC 3339.
runtime_deprecates = try("${replace(substr(local.runtime.time_deprecated, 0, 19), " ", "T")}Z", null)
}
check "runtime_deprecation_window" {
assert {
condition = local.runtime_deprecates == null || timecmp(timeadd(plantimestamp(), "2160h"), local.runtime_deprecates) < 0
error_message = "${var.runtime_name} is deprecated on ${local.runtime_deprecates}. After that no new functions can be created on it and it receives no patches."
}
}
A failed check is a warning, not an error, so every plan starts flagging the date 90 days out without blocking anything. The Terragrunt side stays small:
# hcl
# live/dev/report-fn/terragrunt.hcl
inputs = {
runtime_name = "python312.ol9"
# runtime_version_id not set: dev follows current, and a new version arrives as a diff
}
# live/prod/report-fn/terragrunt.hcl
inputs = {
runtime_name = "python312.ol9"
runtime_version_id = "<version OCID dev ran on>" # promoted, never resolved
}
Moving existing functions
Every module that sets the top-level image now prints a deprecation warning. For functions that stay image-based, move image and image_digest into source_details { source_type = "CONTAINER_IMAGE" }. The provider already records source_details as computed on existing functions, so expect an in-place plan, and check it for “must be replaced” before you apply.
Moving a function from an image to a ZIP is a different operation. source_type is ForceNew in the provider and the service refuses the conversion, so you get a new OCID and a new invoke_endpoint, the URL the provider documents as never changing for the lifetime of a function. create_before_destroy does not help, because display_name is also ForceNew and must be unique within the application. Ship the code-only function under a new name, repoint the API Gateway routes, event rules and alarms, then delete the old one.
What we run on OCI ourselves
Our OCI defaults come from the practice’s own Tech Catalog: compartments and the VCN as code in OpenTofu under Terragrunt, instance and resource principals instead of static keys, defined tags from day one.
Our own company KPI pipeline runs on OCI as well: Container Instances, Autonomous Data Warehouse, OCI Container Registry, OCI Vault read through instance principals, and Notifications to Slack, with AWS as the fallback. It does not use Functions, and the 300-second synchronous timeout is why.
When code-only is the wrong call
- You need custom OS packages, your own base image, or image scanning and signing in the pipeline. Oracle’s guidance keeps those on images.
- The work runs long. Synchronous invocations stop at 300 seconds; detached ones stretch to 3600 through
detached_mode_timeout_in_seconds, without the request-response shape. A long-lived process belongs on Container Instances or OKE. - Nobody will own the runtime calendar. Code-only takes patching off your team, not the decision about when to move. If no one watches deprecation dates, an image you rebuild on your own schedule is safer.
- You planned to push archives straight from Terraform with
DIRECT_ARCHIVE. Fine for a demo; at estate scale it bloats state and plans.
For most event handlers, webhooks and small glue jobs, code-only removes an image pipeline that never earned its keep. Pick the runtime mode before the first deploy, not after the first surprise.
Weighing OCI next to AWS? Naviteq’s senior platform team builds OCI landing zones to the same standard as the AWS side for SaaS, FinTech, and Enterprise teams across the US, EU, and Israel. Let’s talk.
Naviteq. DevOps, FinOps and AI-driven cloud automation, delivered at scale.