Skip to content
Settings

run — runtime modes and operational semantics

cyoda-go version 0.6.2

run — supported ways to start cyoda-go: binary, Docker, Docker Compose, Kubernetes (Helm), and development scripts.

# Binary (default mode — no flags)
cyoda
# Docker
docker run --rm -p 127.0.0.1:8080:8080 -p 127.0.0.1:9090:9090 -p 127.0.0.1:9091:9091 \
ghcr.io/cyoda-platform/cyoda:latest
# Docker Compose (bundled compose.yaml)
docker compose -f deploy/docker/compose.yaml up
# Helm (Kubernetes)
helm install cyoda ./deploy/helm/cyoda \
--set postgres.existingSecret=cyoda-pg \
--set jwt.existingSecret=cyoda-jwt
# Dev script (in-memory, mock auth)
./scripts/dev/run-local.sh

cyoda-go is a single-process, multi-tenant REST and gRPC API server. It starts in serving mode when invoked with no subcommand. All configuration is via environment variables with a CYODA_ prefix. The binary, Docker image, and Helm chart run the same binary; only the environment configuration differs across run modes.

The process binds three TCP listeners concurrently: the REST API (default port 8080), gRPC (default port 9090), and an admin server (default port 9091). The admin server hosts health probes and the Prometheus metrics endpoint. On receiving SIGINT or SIGTERM, the server drains in-flight HTTP and admin requests within a 10-second deadline, then closes the storage backend and exits.

No systemd unit files ship in the repository. Process supervision (systemd, runit, s6, etc.) is the operator’s responsibility when running the binary directly outside of Docker or Kubernetes.

The prebuilt binary is the canonical artifact. Build from source or download from the GitHub release page.

Build from source:

go build -o bin/cyoda ./cmd/cyoda

Run (default — in-memory storage, mock auth):

./bin/cyoda

Run with SQLite after init:

cyoda init
cyoda

cyoda init writes ~/.config/cyoda/cyoda.env with CYODA_STORAGE_BACKEND=sqlite. The binary loads that file via app.LoadEnvFiles() at startup.

Required env vars for postgres + JWT mode:

export CYODA_STORAGE_BACKEND=postgres
export CYODA_POSTGRES_URL=postgres://user:pass@host:5432/dbname
export CYODA_IAM_MODE=jwt
export CYODA_REQUIRE_JWT=true
export CYODA_JWT_SIGNING_KEY_FILE=/run/secrets/signing.pem
cyoda

The binary accepts env vars from the process environment, from .env files loaded by CYODA_PROFILES, and from the user config written by cyoda init. The CYODA_PROFILES variable selects which .env profile files to load from the current working directory. For example, CYODA_PROFILES=postgres,jwt loads .env.postgres then .env.jwt from the working directory. The user config at ~/.config/cyoda/cyoda.env (written by cyoda init) is always loaded automatically as a separate step — it is not a profile file.

Image: ghcr.io/cyoda-platform/cyoda:latest

The image uses gcr.io/distroless/static as its base. The binary is placed at /cyoda. The container runs as UID/GID 65532:65532 (non-root). /var/lib/cyoda is pre-staged with ownership 65532:65532 and is the intended mount point for persistent SQLite data.

Exposed ports: 8080 (HTTP), 9090 (gRPC), 9091 (admin).

The entrypoint is /cyoda with no default arguments. Subcommands (init, health, migrate) are passed as Docker CMD arguments.

Minimal run (in-memory + mock auth):

docker run --rm \
-p 127.0.0.1:8080:8080 \
-p 127.0.0.1:9090:9090 \
-p 127.0.0.1:9091:9091 \
-e CYODA_ADMIN_BIND_ADDRESS=0.0.0.0 \
ghcr.io/cyoda-platform/cyoda:latest

CYODA_ADMIN_BIND_ADDRESS=0.0.0.0 is required when running in Docker so the health probes on port 9091 are reachable from outside the container. Without it, the admin server binds to loopback (127.0.0.1) inside the container and /livez and /readyz are unreachable.

SQLite with persistent volume:

docker run --rm \
-p 127.0.0.1:8080:8080 \
-p 127.0.0.1:9090:9090 \
-p 127.0.0.1:9091:9091 \
-e CYODA_STORAGE_BACKEND=sqlite \
-e CYODA_SQLITE_PATH=/var/lib/cyoda/cyoda.db \
-e CYODA_ADMIN_BIND_ADDRESS=0.0.0.0 \
-v cyoda-data:/var/lib/cyoda \
ghcr.io/cyoda-platform/cyoda:latest

Postgres + JWT (production-shaped):

docker run --rm \
-p 127.0.0.1:8080:8080 \
-p 127.0.0.1:9090:9090 \
-p 127.0.0.1:9091:9091 \
-e CYODA_STORAGE_BACKEND=postgres \
-e CYODA_POSTGRES_URL=postgres://cyoda:secret@db:5432/cyoda \
-e CYODA_IAM_MODE=jwt \
-e CYODA_REQUIRE_JWT=true \
-e CYODA_JWT_SIGNING_KEY_FILE=/run/secrets/signing.pem \
-v /path/to/signing.pem:/run/secrets/signing.pem:ro \
-e CYODA_ADMIN_BIND_ADDRESS=0.0.0.0 \
ghcr.io/cyoda-platform/cyoda:latest

The repository ships a bundled compose file at deploy/docker/compose.yaml.

Default compose configuration:

services:
cyoda:
image: ghcr.io/cyoda-platform/cyoda:latest
ports:
- "127.0.0.1:8080:8080"
- "127.0.0.1:9090:9090"
- "127.0.0.1:9091:9091"
environment:
CYODA_STORAGE_BACKEND: sqlite
CYODA_SQLITE_PATH: /var/lib/cyoda/cyoda.db
CYODA_ADMIN_BIND_ADDRESS: 0.0.0.0
volumes:
- cyoda-data:/var/lib/cyoda
healthcheck:
test: ["CMD", "/cyoda", "health"]
interval: 10s
timeout: 3s
start_period: 30s
retries: 3

The bundled compose file uses SQLite + mock auth by default. The compose healthcheck calls cyoda health, which GETs /readyz on the admin port with a 2-second client timeout (see cmd/cyoda/health.go).

Run the bundled compose file:

docker compose -f deploy/docker/compose.yaml up

Enable JWT auth before starting compose (file-mount approach):

Do not pass CYODA_JWT_SIGNING_KEY as an inline environment variable through docker compose — multi-line PEM content does not survive shell interpolation or YAML env-var parsing reliably. Always use CYODA_JWT_SIGNING_KEY_FILE with a volume mount.

./secrets/signing.pem
# 1. Place the signing key on the host (outside the compose dir if gitignored):
# 2. docker-compose.yaml snippet (add to the cyoda service):
# volumes:
# - ./secrets/signing.pem:/run/secrets/signing.pem:ro
# environment:
# CYODA_IAM_MODE: jwt
# CYODA_REQUIRE_JWT: "true"
# CYODA_JWT_SIGNING_KEY_FILE: /run/secrets/signing.pem
# CYODA_JWT_ISSUER: https://auth.example.com
# CYODA_JWT_AUDIENCE: cyoda-api
# 3. Launch:
docker compose up

Use a custom image (e.g. a local dev build):

CYODA_IMAGE=ghcr.io/cyoda-platform/cyoda:dev \
docker compose -f deploy/docker/compose.yaml up

The compose file reads ${CYODA_IMAGE:-ghcr.io/cyoda-platform/cyoda:latest} for the image name.

Adjust start_period for slow environments (Postgres migrations, cluster mode) by editing the healthcheck.start_period field in deploy/docker/compose.yaml.

cyoda-go ships a Helm chart at deploy/helm/cyoda/. The chart renders a StatefulSet (with Parallel pod management policy), ClusterIP Service, headless Service for gossip, ConfigMap for non-sensitive env vars, projected-volume Secrets for credentials, and optional Gateway API routes, Ingress, HorizontalPodAutoscaler, PodDisruptionBudget, NetworkPolicy, ServiceAccount, ServiceMonitor, and a pre-upgrade migration Job.

The chart is not yet published to a Helm repository. Install directly from the local path or from a cloned repository. See helm for the complete Helm reference.

Minimal install:

helm install cyoda ./deploy/helm/cyoda \
--set postgres.existingSecret=cyoda-pg \
--set jwt.existingSecret=cyoda-jwt

Upgrade:

helm upgrade --install cyoda ./deploy/helm/cyoda \
--set postgres.existingSecret=cyoda-pg \
--set jwt.existingSecret=cyoda-jwt

Developer convenience scripts live under scripts/dev/. These are not canonical provisioning artifacts. Canonical artifacts are in deploy/.

  • scripts/dev/run-local.sh — runs cyoda-go via go run ./cmd/cyoda using the local profile (in-memory storage, mock auth). Override with CYODA_PROFILES=postgres,otel ./scripts/dev/run-local.sh.
  • scripts/dev/run-docker-dev.sh — builds the binary from source for the host platform (linux/amd64 or linux/arm64), builds a local Docker image tagged ghcr.io/cyoda-platform/cyoda:dev, and runs it via docker compose -f deploy/docker/compose.yaml up. Generates a fresh JWT signing key and randomized bootstrap client secret per run. Intended for contributors testing local changes in a container before they land.

Run with in-memory storage and mock auth (go run):

./scripts/dev/run-local.sh

Run with a custom profile set:

CYODA_PROFILES=postgres,otel ./scripts/dev/run-local.sh

Build and run a local Docker image:

./scripts/dev/run-docker-dev.sh
  • SIGINT (Ctrl+C) — triggers graceful shutdown. HTTP and admin servers drain in-flight requests within a 10-second deadline. The storage backend is closed. The process exits with code 0.
  • SIGTERM — same behavior as SIGINT. Kubernetes sends SIGTERM when a pod is evicted or deleted.
  • SIGPIPE — ignored. When the binary is piped through tee (e.g. ./bin/cyoda | tee log) and Ctrl+C kills tee first, the broken pipe would cause the binary to exit immediately before the SIGINT handler runs. Ignoring SIGPIPE lets the write fail silently while the graceful shutdown proceeds. (Source: cmd/cyoda/main.go, signal.Ignore(syscall.SIGPIPE).)

Signal handling is established in main() before the listeners start. The signal channel has buffer size 1.

Both probes are served on CYODA_ADMIN_PORT (default 9091) at CYODA_ADMIN_BIND_ADDRESS (default 127.0.0.1). Both endpoints are unauthenticated — authentication is not applied to /livez or /readyz regardless of CYODA_METRICS_BEARER or CYODA_METRICS_REQUIRE_AUTH.

  • GET /livez — liveness probe. Returns 200 OK with body ok when the admin server is accepting connections. No business logic check is performed.
  • GET /readyz — readiness probe. Returns 200 OK with body ok when the server has completed startup and is ready to serve requests. Returns a non-200 status while the storage backend is initializing or migrations are pending.

The cyoda health subcommand calls /readyz on the admin port with a 2-second HTTP client timeout and exits 0 on 200 OK, 1 otherwise. This is the implementation behind Docker’s HEALTHCHECK: CMD /cyoda health and is valid as a readiness check for any init system.

Admin bind address in container environments: set CYODA_ADMIN_BIND_ADDRESS=0.0.0.0 to make health probes reachable from outside the container. The Kubernetes Helm chart sets this value in its ConfigMap. Without it, /livez and /readyz are inaccessible from the kubelet or Docker healthcheck daemon.

The graceful shutdown deadline is 10 seconds, applied separately to the HTTP server and the admin server. This value is hardcoded in cmd/cyoda/main.go:

shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)

After both HTTP servers shut down, app.Close() is called to release backend resources (database connection pools, cluster membership). There is no separate configurable timeout for app.Close() — it runs to completion after the HTTP deadline.

In Kubernetes, the pod terminationGracePeriodSeconds (default 30s) must be greater than 10s to allow the HTTP drain to complete before the kubelet sends SIGKILL.

All ports are configurable via environment variables. The defaults:

  • HTTP REST API — port 8080, bind address 0.0.0.0 (all interfaces). Controlled by CYODA_HTTP_PORT. All entity, model, workflow, search, and auth endpoints. Context path prefix: CYODA_CONTEXT_PATH (default /api).
  • gRPC — port 9090, bind address 0.0.0.0 (all interfaces). Controlled by CYODA_GRPC_PORT. Externalized-processor streaming (processor and criteria dispatch). The bind expression is fmt.Sprintf(":%d", cfg.GRPC.Port) — all interfaces, not loopback.
  • Admin — port 9091, bind address 127.0.0.1 (loopback) by default. Controlled by CYODA_ADMIN_PORT and CYODA_ADMIN_BIND_ADDRESS. Hosts /livez, /readyz, and /metrics. Set CYODA_ADMIN_BIND_ADDRESS=0.0.0.0 in Docker/Kubernetes to make probes reachable.
  • Gossip (cluster mode only) — port 7946 TCP+UDP. Controlled by CYODA_GOSSIP_ADDR (default :7946). Used by the memberlist gossip protocol for cluster membership and SWIM health checking. Active only when CYODA_CLUSTER_ENABLED=true.

Binary — postgres + JWT, production-shaped:

export CYODA_STORAGE_BACKEND=postgres
export CYODA_POSTGRES_URL_FILE=/run/secrets/postgres-url
export CYODA_IAM_MODE=jwt
export CYODA_REQUIRE_JWT=true
export CYODA_JWT_SIGNING_KEY_FILE=/run/secrets/signing.pem
export CYODA_JWT_ISSUER=https://auth.example.com
export CYODA_JWT_AUDIENCE=cyoda-api
./bin/cyoda

Docker — in-memory + OTel tracing:

docker run --rm \
-p 127.0.0.1:8080:8080 \
-p 127.0.0.1:9090:9090 \
-p 127.0.0.1:9091:9091 \
-e CYODA_ADMIN_BIND_ADDRESS=0.0.0.0 \
-e CYODA_OTEL_ENABLED=true \
-e OTEL_EXPORTER_OTLP_ENDPOINT=http://host.docker.internal:4318 \
-e OTEL_SERVICE_NAME=cyoda \
ghcr.io/cyoda-platform/cyoda:latest

Docker — suppress banner (CI):

docker run --rm \
-e CYODA_SUPPRESS_BANNER=true \
-e CYODA_ADMIN_BIND_ADDRESS=0.0.0.0 \
ghcr.io/cyoda-platform/cyoda:latest

Check health from outside the container:

curl -s http://localhost:9091/readyz

Binary — check readiness via subcommand:

./bin/cyoda health
echo $? # 0 = ready, 1 = not ready or error

Docker Compose — production JWT (file-mount):

# Mount the PEM file and reference it via CYODA_JWT_SIGNING_KEY_FILE.
# Do not export CYODA_JWT_SIGNING_KEY inline — multi-line PEM does not
# survive shell → docker-compose env interpolation reliably.
# See the Docker Compose section above for the full snippet.
docker compose up
  • cli
  • cli.serve
  • cli.init
  • cli.health
  • quickstart
  • helm
  • config
  • config.database
  • config.auth
  • telemetry
  • cyoda help cli — cyoda is a Go binary that embeds the full platform: API server, schema engine, workflow runner, and storage plugins. Invoked with no subcommand, it starts the server using environment-provided configuration. Subcommands provide operational affordances — init for first-run bootstrap, health for liveness probes, migrate for schema migrations.
  • cyoda help cli serve — Starting with no subcommand loads configuration from environment variables, validates the IAM mode, and binds the REST, gRPC, and admin listeners. The server is single-process, multi-tenant, and stateful — storage is provided by one of the pluggable backends (memory, sqlite, or postgres); see cyoda help config for backend selection.
  • cyoda help cli initcyoda init is the recommended first step for local desktop use. It writes a minimal user config file that sets CYODA_STORAGE_BACKEND=sqlite, enabling persistent local storage without requiring a database server.
  • cyoda help cli healthcyoda health sends an HTTP GET to http://127.0.0.1:<port>/readyz and exits 0 if the server responds with HTTP 200. Any non-200 response or connection error causes exit 1.
  • cyoda help quickstart — cyoda-go is a single-process, multi-tenant REST and gRPC API server backed by a pluggable embedded database management system. Storage backends are memory, sqlite, and postgres; authentication modes are mock and jwt. All configuration is via environment variables with a CYODA_ prefix.
  • cyoda help helm — The chart at deploy/helm/cyoda deploys cyoda-go on Kubernetes as a StatefulSet backed by an external PostgreSQL database. The chart requires Kubernetes >=1.31.0. Chart version: 0.1.0. App version: 0.1.0 (synchronized to the binary by the bump-chart-appversion.yml CI workflow).
  • cyoda help config — Environment variables beat default values. The _FILE suffix variant takes precedence over the plain variable when both are set — for example, CYODA_POSTGRES_URL_FILE=/etc/secrets/db-url wins over CYODA_POSTGRES_URL. There are no command-line flags for configuration values; env vars are the sole configuration surface.
  • cyoda help config database — config.database — storage backend selection and per-backend connection settings.
  • cyoda help config auth — config.auth — IAM mode, JWT issuer, HMAC secret, and admin bootstrap controls.
  • cyoda help telemetry — cyoda-go integrates the OpenTelemetry Go SDK (go.opentelemetry.io/otel). When CYODA_OTEL_ENABLED=true, the binary initializes a trace provider and a meter provider at startup using OTLP HTTP exporters. When CYODA_OTEL_ENABLED=false (default), no OTel SDK is initialized and no spans or metrics are emitted; the global OTel provider remains a no-op.