Octov0.11.7
Deployment

Self-Hosted Integrations

Bake your flows and resources into an image built on the public runtime, and run it on Cloud Run or any container platform.

An integration is YAML plus the resources it references, and the public runtime image knows how to run a directory of exactly that. Copy your config into an image built on juancavallotti/octo-runtime and you get a self-contained, immutable artifact that runs anywhere a container runs, with no cluster, no orchestrator, and no Octo platform to operate.

That artifact fits serverless container platforms particularly well. The runtime binds 0.0.0.0:8080 by default and speaks plain HTTP, the contract Cloud Run, Fly, Container Apps, and App Runner expect. This page covers building the image, deploying it to Cloud Run, and the constraints that scale-to-zero puts on your flows.

You can also mount a config directory into the stock image instead of baking one in: see Running flows locally. Mounting is right for a laptop or a VM; baking is right when the platform pulls an image and there is nowhere to mount from.

Lay out the integration

The runtime loads every .yaml/.yml file at the top level of its config directory and merges them into one config. Subdirectories are ignored for that scan, but they are still readable as resources, since the config directory is the resource root:

orders/
├── Dockerfile
├── orders.yaml           # loaded
├── notifications.yaml    # loaded, merged with orders.yaml
└── templates/
    └── receipt.tmpl      # a template resource, referenced as templates/receipt.tmpl

Names do not matter, only the extension. Duplicate flow, connector, or processor names across files are a load error, and service: may appear in at most one file.

Here is orders.yaml. Note what it does not do: it never hardcodes a port, and it never hardcodes a secret.

orders.yaml
service:
  name: orders

env:
  - name: PORT
    default: "8080"       # Cloud Run injects PORT; the default covers local runs
  - name: STRIPE_KEY
    required: true        # must come from the environment (see below)

resources:
  templates:
    - resource: templates/receipt.tmpl
      as: receipt

connectors:
  - name: api
    type: http
    settings:
      port: ${PORT}       # a bare ${VAR} keeps its native type, so this is an int

flows:
  - name: receipt
    source:
      connector: api
      type: http
      settings:
        path: /orders/{id}/receipt
    process:
      - type: template-resource
        settings:
          id: receipt
          rawBody: true
          contentType: text/html; charset=utf-8

required: true is not satisfied by a default:. The variable must come from the OS environment or a .env file, so a deploy that forgets to wire STRIPE_KEY fails at config load rather than on the first message.

Bake it into an image

The public runtime image ends with CMD ["run", "--config", "/etc/octo/integrations"], so a COPY into that directory is the entire Dockerfile:

FROM juancavallotti/octo-runtime:0.11.7COPY . /etc/octo/integrations/

Pin the tag. latest moves with every Octo release, and an integration that was verified against one runtime should not silently pick up another.

Two things about that base image shape how you extend it. There is no shell: the base is distroless, so a RUN instruction has nothing to execute and will fail, leaving COPY as all you get. Generate or template anything you need in a builder stage and copy the result across. And it runs as nonroot, with files copied in root-owned and world-readable, which is all the runtime needs to read its config.

Do not COPY a .env file containing secrets. Anyone who can pull the image can read it. Secrets belong in the platform's environment or secret store, which the runtime reads as ordinary OS environment variables, and OS environment wins over every .env source anyway.

Build and run it locally first. The image is the deployable, so exercise it as one:

docker build -t orders:1.0.0 .
docker run --rm -p 8080:8080 -e STRIPE_KEY=sk_test_… orders:1.0.0
curl localhost:8080/orders/42/receipt

Deploy to Cloud Run

Nothing about the image is Cloud Run specific. It listens on 0.0.0.0:8080, logs to stderr, and exits on SIGTERM, which is the whole container contract.

Push the image to Artifact Registry

Cloud Run runs linux/amd64 only. On an Apple Silicon machine docker build produces arm64 by default and the deploy will fail at startup, so set --platform explicitly.

PROJECT=$(gcloud config get-value project)
REPO=us-central1-docker.pkg.dev/$PROJECT/octo

gcloud artifacts repositories create octo \
  --repository-format=docker --location=us-central1

gcloud auth configure-docker us-central1-docker.pkg.dev
docker build --platform linux/amd64 -t $REPO/orders:1.0.0 .
docker push $REPO/orders:1.0.0

Store the secrets

echo -n 'sk_live_…' | gcloud secrets create stripe-key --data-file=-

Deploy

gcloud run deploy orders \
  --image $REPO/orders:1.0.0 \
  --region us-central1 \
  --port 8080 \
  --set-env-vars LOG_LEVEL=info \
  --set-secrets STRIPE_KEY=stripe-key:latest \
  --allow-unauthenticated

Cloud Run injects PORT into the container and forbids you from setting it yourself, which is why the config reads port: ${PORT} rather than pinning a number. The values from --set-env-vars and --set-secrets arrive as OS environment variables and satisfy the env: block the same way a local shell would.

gcloud run deploy will happily take a source directory and build for you, but prefer pushing the image yourself: the point of baking the config in is that the artifact you tested is bit-for-bit the artifact that runs.

Health checks

The runtime has no built-in health endpoint; it serves only the routes your flows declare. Cloud Run's default startup probe is a TCP check against the container port, which the HTTP connector satisfies as soon as it binds, so the default works without any help.

If you want a real readiness signal, declare one as a flow and point an HTTP startup probe at it:

  - name: healthz
    source:
      connector: api
      type: http
      settings:
        path: /healthz
    process:
      - type: set-payload
        settings:
          value: '{"status": "ok"}'

Call other Cloud Run services

A private Cloud Run service requires an OIDC identity token whose audience is the receiving service's URL, and the caller's service account needs roles/run.invoker on it.

The http-client connector's gcp auth does this with no secret to manage. It asks the instance metadata server for a token minted for the service account the revision runs as, and the audience defaults to the connector's own baseURL, which is the receiving service:

env:
  - name: ORDERS_URL
    required: true
  - name: API_AUTH
    default: ""              # empty locally; set to "gcp" on Cloud Run

connectors:
  - name: orders
    type: http-client
    settings:
      baseURL: ${ORDERS_URL}
      auth:
        type: ${API_AUTH}

Name the service account the revision runs as. Cloud Run's default is the Compute Engine default account:

CALLER_SA="$(gcloud projects describe "$(gcloud config get-value project)" --format='value(projectNumber)')-compute@developer.gserviceaccount.com"

Deploy with that account and the variable set:

gcloud run deploy my-integration --region us-central1 --service-account "$CALLER_SA" --set-env-vars API_AUTH=gcp,ORDERS_URL=https://orders-abc123-uc.a.run.app

Then let it invoke the callee. The binding is scoped to that one service, and add-iam-policy-binding needs the region:

gcloud run services add-iam-policy-binding orders --region us-central1 --member "serviceAccount:$CALLER_SA" --role roles/run.invoker

The callee itself is deployed with --no-allow-unauthenticated; that, plus this binding, is what the identity token is checked against.

The same auth reaches Google APIs directly: set gcpToken: access and name the scopes. See the http-client reference.

Driving auth.type from an environment variable is what lets the same config run on a laptop, where no metadata server exists. Empty means no auth; the scheme turns on only where it can work.

Shutdown

Cloud Run sends SIGTERM and then waits before killing the container. The runtime treats that as a graceful stop: sources stop accepting work, connectors unwind in reverse order, and the HTTP server drains in-flight requests. Requests still parked waiting on a flow result are released with a 503, so keep flows well under the platform's grace period.

What does not survive scale-to-zero

The public image ships the standalone services provider a local octo run uses: in-process queues, an in-memory KV store, and no leader election, all of it per-process. On a platform that starts, stops, and duplicates your container at will, three consequences follow.

FeatureOn a serverless platform
HTTP flowsWork exactly as they do locally. This is the sweet spot.
KV storeIn memory, per instance. Two instances do not share it, and a scaled-to-zero instance loses it. Use an external store for anything that must persist.
Queues and eventsIn process. A message queued by one instance is invisible to every other, and anything in flight is lost when the instance goes away.
CronOnly fires while an instance is running. At zero instances, nothing fires. With N instances, the schedule fires N times: the standalone provider has no leader election, so every process considers itself the leader.

The practical rule: on a scale-to-zero platform, ship request/response HTTP flows and reach for managed infrastructure for anything stateful. Instead of a cron source, have Cloud Scheduler call an HTTP flow, which keeps the schedule outside the container.

For a durable cron, a shared KV, or queues that outlive an instance, there are two ways to get them without giving up serverless.

Keep the state outside the container. Cloud Scheduler calling an HTTP flow instead of a cron source, Firestore or Cloud SQL instead of the KV store, a Pub/Sub push subscription pointed at an HTTP flow instead of a queue. Nothing in Octo changes.

Or run the octo-api image and give the runtime a platform. It is the same engine with a different services provider: the KV store, secrets, queues, leases and leader election are all delegated to an HTTP server you implement, typically a second Cloud Run service in front of Firestore, Secret Manager and Pub/Sub. Flows stay unchanged, the KV store is shared because it is yours, and cron fires once across every instance, but only once you implement leader election. That is the one part that does not quietly degrade: leave it out and every campaign is refused rather than granted, because a no-op election makes every instance the leader. Everything else you skip degrades. See The platform API.

The platform's own cluster deployment is the third option, with leader-elected crons, an orchestrator-backed KV and NATS-backed queues built in. See Deployment overview.

Pin the instance count (--min-instances=1 --max-instances=1) and a standalone cron source will behave: one always-on process, firing once. You are then paying for an always-on container, which removes most of the reason to reach for Cloud Run. Prefer Cloud Scheduler.

Other platforms

The image carries no assumptions beyond "listen on a port, log to stderr, handle SIGTERM", so the same artifact deploys unchanged elsewhere. Two worked examples follow: one entirely from a web console, one from a CLI.

App Runner can only pull from Amazon ECR, so push the image there first. This is the one step that is not clickable:

ACCOUNT=$(aws sts get-caller-identity --query Account --output text)
ECR=$ACCOUNT.dkr.ecr.us-east-1.amazonaws.com

aws ecr create-repository --repository-name orders
aws ecr get-login-password | docker login --username AWS --password-stdin $ECR

docker build --platform linux/amd64 -t $ECR/orders:1.0.0 .
docker push $ECR/orders:1.0.0

App Runner runs x86_64 images only, so --platform linux/amd64 is required, not optional, when you build on an ARM machine.

Then, in the AWS console:

Create the service

Go to App Runner → Create service. For Source, choose Container registry, provider Amazon ECR, and Browse to the orders image you just pushed. Pick Manual deployment unless you want every new image tag to roll out automatically.

App Runner needs permission to pull from ECR: when prompted for an ECR access role, let the console Create new service role.

Configure the service

Name the service orders and set Port to 8080, which is what the HTTP connector binds by default.

Under Environment variables, add each variable the config's env: block declares. LOG_LEVEL can be a plaintext value; for STRIPE_KEY, choose the Secrets Manager source and reference the secret's ARN. Either way it arrives in the container as an ordinary environment variable, which is what the runtime reads, and OS environment beats every other source.

Set the health check

The default health check is TCP against the service port, which the HTTP connector satisfies as soon as it binds, so the default works with no flow of your own.

To use the /healthz flow from above instead, open Health check, switch the protocol to HTTP, and set the path to /healthz.

Create and deploy

Create & deploy, then watch the Event log tab. When it reports the service is running, the Default domain at the top of the page serves your flows.

App Runner does not scale to zero by default (it idles at one instance), so a cron source will actually fire. It still scales out under load, and every instance fires its own tick, so cap Max size at 1 in the auto-scaling configuration if you depend on that.

The caveats travel with the runtime, not the platform: all of these scale to zero or scale out, so the stateless rule holds everywhere.

Where to go next

On this page