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.tmplNames 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.
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-8required: 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/receiptDeploy 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.0Store 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-unauthenticatedCloud 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.appThen 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.invokerThe 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.
| Feature | On a serverless platform |
|---|---|
| HTTP flows | Work exactly as they do locally. This is the sweet spot. |
| KV store | In 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 events | In process. A message queued by one instance is invisible to every other, and anything in flight is lost when the instance goes away. |
| Cron | Only 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.0App 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
- Docker images: every published image and how each one is built.
- Running flows locally: mounting
a config instead of baking one in, plus
--watchand one-shotinvoke. - Resources: how env files and templates resolve against the config directory.
- Environment and configuration:
env:declarations,${VAR}substitution, and precedence. - Deployment overview: when you outgrow a single container and want the full platform.