Self-hosted PaaS

A self-hosted PaaS that also secures what it runs

Octelium is a free and open source, self-hosted zero trust access platform. Managed containers let the Cluster deploy, scale and serve your containerized applications as Services, so the thing that runs an application and the thing that decides who may reach it are the same declarative resource.

  • Free and open source
  • Managed TLS and DNS

Every request from a human, a browser, a CLI, a workload or an AI agent is authenticated against your identity providers, authorized per request at the application layer by the Octelium Service in front of the resource, exported to your SIEM and observability stack whether it is allowed or denied, and only then routed to the upstream HTTP API, database, SSH server, Kubernetes cluster, LLM or MCP server.

Signing in once, then reaching Services with and without a client

  • A browser opens grafana.acme.com and is redirected to the Cluster sign-in page at acme.com/login.
  • jane signs in with a passkey verified by a biometric on the device, and the browser returns to the Grafana dashboard at grafana.acme.com with no client installed.
  • In a terminal, octelium connect -d opens the browser at acme.com/callback/success/approval, where the same browser session approves the login with one click.
  • ~ ❯ octelium connect -d
  • Please authenticate yourself using Octelium web Portal
  • Waiting for you to approve the login in your browser. Press Ctrl-C to cancel.
  • You are now authenticated as jane
  • Octelium has started running in detached mode.
  • ~ ❯ curl -s my-api/v1/users/42 | jq
  • {
  • "id": 42,
  • "name": "Jane Doe",
  • "email": "jane@acme.com"
  • }
  • ~ ❯ psql -h pg-prod -d shop
  • psql (17.4)
  • Type "help" for help.
  • shop=> SELECT * FROM orders LIMIT 3;
  • id | status | total
  • -----+---------+--------
  • 981 | paid | 129.00
  • 982 | shipped | 48.50
  • 983 | paid | 310.25
  • (3 rows)
  • shop=> \q
  • ~ ❯ ssh bastion
  • Welcome to Ubuntu 24.04.1 LTS (GNU/Linux 6.8.0-1021-aws x86_64)

Requests authorized one by one against CEL rules in the HTTP, LLM and Kubernetes modes

  • HTTP: the HTTP Service billing is configured as follows.
  • kind: Service
  • metadata:
  • name: billing
  • spec:
  • mode: HTTP
  • config:
  • authorization:
  • inlinePolicies:
  • - spec:
  • rules:
  • - effect: ALLOW
  • condition:
  • all:
  • of:
  • - match: '"finance" in ctx.user.spec.groups'
  • - match: ctx.request.http.method == "GET"
  • - match: ctx.request.http.path.startsWith("/invoices")
  • jane, a member of the finance Group, runs curl -i billing/invoices/42 and gets HTTP/1.1 200 OK Content-Type: application/json {"id":42,"status":"paid","total":1290}. The access log records ALLOWED GET /invoices/42 with POLICY_MATCH.
  • jane, a member of the finance Group, runs curl -i -X DELETE billing/invoices/42 and gets HTTP/1.1 403 Forbidden X-Octelium-Unauthorized: true Content-Length: 0. The access log records DENIED DELETE /invoices/42 with NO_POLICY_MATCH.
  • LLM: the LLM Service openai is configured as follows.
  • kind: Service
  • metadata:
  • name: openai
  • spec:
  • mode: LLM
  • config:
  • authorization:
  • inlinePolicies:
  • - spec:
  • rules:
  • - effect: ALLOW
  • condition:
  • any:
  • of:
  • - match: '"ai-admins" in ctx.user.spec.groups'
  • - all:
  • of:
  • - match: '"ai-agents" in ctx.user.spec.groups'
  • - match: ctx.request.llm.operation == "GENERATE"
  • - match: ctx.request.llm.model == "gpt-5-mini"
  • support-bot, a member of the ai-agents Group, runs curl openai/v1/responses --json '{"model":"gpt-5-mini","input":"Hello"}' and gets { "id": "resp_68e8e1f0c4d0", "object": "response", "created_at": 1791628935, "status": "completed", "model": "gpt-5-mini-2025-08-07",. The access log records ALLOWED RESPONSES gpt-5-mini with POLICY_MATCH.
  • support-bot, a member of the ai-agents Group, runs curl openai/v1/responses --json '{"model":"gpt-5","input":"Hello"}' and gets {"error":{"message":"Octelium: unauthorized request","type":"permission_error","param":null,"code":"octelium_unauthorized"}}. The access log records DENIED RESPONSES gpt-5 with NO_POLICY_MATCH.
  • Kubernetes: the KUBERNETES Service k8s-prod is configured as follows.
  • kind: Service
  • metadata:
  • name: k8s-prod
  • spec:
  • mode: KUBERNETES
  • config:
  • authorization:
  • inlinePolicies:
  • - spec:
  • rules:
  • - effect: ALLOW
  • condition:
  • all:
  • of:
  • - match: '"sre-agents" in ctx.user.spec.groups'
  • - match: ctx.request.kubernetes.verb in ["get", "list"]
  • - match: ctx.request.kubernetes.namespace == "production"
  • - not: ctx.request.kubernetes.resource == "secrets"
  • ops-agent, a member of the sre-agents Group, runs kubectl get configmaps -n production and gets NAME DATA AGE app-config 4 12d feature-flags 2 3d kube-root-ca.crt 1 12d. The access log records ALLOWED list configmaps · production with POLICY_MATCH.
  • ops-agent, a member of the sre-agents Group, runs kubectl get secrets -n production and gets Error from server (Forbidden): Octelium: Unauthorized request. The access log records DENIED list secrets · production with NO_POLICY_MATCH.
env.yaml
Configuration from SecretsEnvironment variables can be filled from Cluster Secrets rather than a checked-in file.
Configuration from Secrets. Environment variables can be filled from Cluster Secrets rather than a checked-in file.
kind: Service
metadata:
  name: api
spec:
  mode: HTTP
  config:
    upstream:
      container:
        port: 8080
        image: ghcr.io/acme/api:3.1.0
        env:
          - name: LOG_LEVEL
            value: info
          - name: DATABASE_PASSWORD
            fromSecret: pg-password
A private registry. Images pull from private registries using credentials held as Cluster Secrets.
kind: Service
metadata:
  name: api
spec:
  mode: HTTP
  config:
    upstream:
      container:
        port: 8080
        image: ghcr.io/acme/api:3.1.0
        credentials:
          usernamePassword:
            username: ghcr-user
            password:
              fromSecret: ghcr-token
Upstream API keys. A deployed application can reach protected APIs without holding their credentials.
kind: Service
metadata:
  name: stripe
spec:
  mode: HTTP
  config:
    upstream:
      url: https://api.stripe.com
    http:
      auth:
        bearer:
          fromSecret: stripe-api-key

Signing in with a passkey and opening the Portal

  • The sign-in page of the Cluster at acme.com/login offers login with Okta, with GitHub, or with a passkey.
  • jane@acme.com chooses Login with a Passkey and verifies it with a biometric on the device through native WebAuthn.
  • The browser is redirected to the Portal at portal.acme.com/services, which lists the Services the User can access:
  • grafana.monitoring: Grafana dashboards, Web App, Namespace monitoring.
  • aws-postgres.production: Amazon RDS for PostgreSQL, PostgreSQL, Namespace production.
  • k8s-prod: Production EKS cluster, Kubernetes, Namespace default.
  • bastion.production: SSH bastion host, SSH, Namespace production.
  • github-mcp: GitHub MCP server, MCP, Namespace default.
  • openai: OpenAI API, AI / LLM, Namespace default.
Visibility and auditing

Identity-aware visibility at the application layer

Emit structured, application-layer access logs in real time and export them through OpenTelemetry OTLP to the observability, analytics and SIEM systems your organization already operates.

Read about visibility and auditing
AccessLogOTLP
ALLOWlinus@acme.compg-prod.dbPOSTGRESquery: select id, email from users limit 50
ALLOWci-runnerprod-k8sKUBERNETESverb=list resource=pods namespace=production
DENYagent-07tools-mcpMCPtools/call name=transfer policy=mcp-tools
ALLOWjane@acme.combastionSSHsession recorded, upstream user=deploy
ALLOWagent-07openaiLLMCHAT_COMPLETIONS model=gpt-5-mini tokens=1284

Cluster resources applied declaratively from YAML with octeliumctl and managed programmatically with the Go, TypeScript, Python and Rust SDKs

  • GitOps: cluster.yaml in the main branch of the ~/octelium repository describes the desired state of the Cluster. Its diff reads as follows, with + for added, ~ for changed and - for removed lines.
  • + kind: Group
  • + metadata:
  • + name: payments
  • + spec: {}
  • + ---
  • + kind: User
  • + metadata:
  • + name: alice
  • + spec:
  • + type: HUMAN
  • + email: alice@acme.com
  • + groups: ["payments"]
  • ---
  • kind: User
  • metadata:
  • name: bob
  • spec:
  • type: HUMAN
  • ~ groups: ["payments"]
  • ---
  • kind: Service
  • metadata:
  • name: billing
  • spec:
  • mode: HTTP
  • config:
  • upstream:
  • ~ url: http://10.0.3.24:8080
  • - ---
  • - kind: Service
  • - metadata:
  • - name: billing-legacy
  • - spec: ⋯
  • - ---
  • - kind: User
  • - metadata:
  • - name: john
  • - spec: ⋯
  • ~/octelium ❯ octeliumctl apply --prune .
  • Group: payments Created
  • User: alice Created
  • User: bob Updated
  • Service: billing.default Updated
  • Service: billing-legacy.default Deleted
  • User: john Deleted
  • Cluster Core resources successfully applied
  • 2 resources created
  • 2 resources updated
  • 2 resources deleted
  • Go: main.go uses the octelium-go SDK to list the Users of the Cluster and update the email and Policies of linus.
  • client, err := octelium.New(ctx)
  • if err != nil {⋯}
  • conn, err := client.Conn(ctx)
  • if err != nil {⋯}
  • api := corev1.NewMainServiceClient(conn)
  • users, err := api.ListUser(ctx, &corev1.ListUserOptions{})
  • if err != nil {⋯}
  • for _, u := range users.Items {
  • fmt.Println(u.Metadata.Name)
  • }
  • user, err := api.GetUser(ctx, &metav1.GetOptions{Name: "linus"})
  • if err != nil {⋯}
  • user.Spec.Email = "linus@acme.com"
  • user.Spec.Authorization = &corev1.User_Spec_Authorization{
  • Policies: []string{"db-readonly"},
  • }
  • if _, err := api.UpdateUser(ctx, user); err != nil {⋯}
  • fmt.Println("updated", user.Metadata.Name)
  • ~/app ❯ go run .
  • alice
  • bob
  • linus
  • updated linus
  • TypeScript: main.ts uses the @octelium/sdk SDK to list the Users of the Cluster and update the email and Policies of linus.
  • const client = await OcteliumClient.create({
  • domain: "acme.com",
  • auth: {⋯},
  • });
  • const users = await client.coreV1.listUser(ListUserOptions.create());
  • for (const u of users.response.items) {
  • console.log(u.metadata?.name);
  • }
  • const { response: user } = await client.coreV1.getUser(
  • GetOptions.create({ name: "linus" }),
  • );
  • user.spec!.email = "linus@acme.com";
  • user.spec!.authorization = User_Spec_Authorization.create({
  • policies: ["db-readonly"],
  • });
  • await client.coreV1.updateUser(user);
  • console.log("updated", user.metadata?.name);
  • await client.close();
  • ~/app ❯ npx tsx main.ts
  • alice
  • bob
  • linus
  • updated linus
  • Python: main.py uses the octelium-sdk SDK to list the Users of the Cluster and update the email and Policies of linus.
  • async with await OcteliumClient.create() as client:
  • users = await client.core_v1.list_user(ListUserOptions())
  • for u in users.items:
  • print(u.metadata.name)
  • user = await client.core_v1.get_user(GetOptions(name="linus"))
  • user.spec.email = "linus@acme.com"
  • user.spec.authorization.policies = ["db-readonly"]
  • await client.core_v1.update_user(user)
  • print("updated", user.metadata.name)
  • ~/app ❯ python main.py
  • alice
  • bob
  • linus
  • updated linus
  • Rust: main.rs uses the octelium SDK to list the Users of the Cluster and update the email and Policies of linus.
  • let client = Client::from_env().await?;
  • let mut api = client.core_v1();
  • let users = api.list_user(ListUserOptions::default()).await?;
  • for u in users.into_inner().items {
  • println!("{}", u.metadata.unwrap_or_default().name);
  • }
  • let options = GetOptions {
  • name: "linus".into(),
  • ..Default::default()
  • };
  • let mut user = api.get_user(options).await?.into_inner();
  • let spec = user.spec.get_or_insert_default();
  • spec.email = "linus@acme.com".into();
  • spec.authorization.get_or_insert_default().policies = vec!["db-readonly".into()];
  • let user = api.update_user(user).await?.into_inner();
  • println!("updated {}", user.metadata.unwrap_or_default().name);
  • ~/app ❯ cargo run -q
  • alice
  • bob
  • linus
  • updated linus
Responsibilities

What a deployment platform has to do, and what does each part

Running an application is only the first of the jobs a platform ends up owning. These are the rest, next to the mechanism in an Octelium Cluster that handles each one.

  1. 01

    Run the container

    A container image is set as the Service upstream and scheduled on the Cluster's data-plane nodes, using the Kubernetes infrastructure the Cluster already runs on. Custom commands and arguments are supported where the image needs them.

    Read more
  2. 02

    Pull it from where it lives

    Public and private registries are both supported, with registry credentials referenced from Cluster Secrets rather than stored as a Kubernetes pull secret you maintain separately.

    Read more
  3. 03

    Configure it

    Environment variables can be literal values, references to Cluster Secrets, or references to an existing Kubernetes secret, so configuration and credentials arrive the same way in every environment.

    Read more
  4. 04

    Scale it and bound it

    Replica counts scale an application up and down, and CPU, memory and extended resource limits are set per Service, including requesting a GPU where the Kubernetes cluster can schedule one.

    Read more
  5. 05

    Give it storage

    Kubernetes volumes are mounted into managed containers, including persistent volume claims and emptyDir volumes with a size limit, so an application that needs state is not excluded.

    Read more
  6. 06

    Put it on a domain

    A published Service gets its own public FQDN under your Cluster's domain, with the public DNS record and the TLS certificate managed by the Cluster rather than by a separate certificate workflow.

    Read more
  7. 07

    Decide who reaches it

    The same Service is either anonymous and public, published behind your identity provider, private to connected clients, or several of those at once, and that is a field rather than a different deployment.

    Read more
  8. 08

    Show what it is doing

    Every request produces an identity-aware, application-layer AccessLog streamed over OpenTelemetry, and health probes and resource limits are part of the same Service definition.

    Read more

Building and pushing images stays in whatever CI you already use. What this replaces is the layer after that: the ingress, the certificate, the authentication in front, and the audit trail behind.

Questions

Frequently asked

What can it deploy?
Any container image that listens on 0.0.0.0 on a known port. That covers web applications built with Next.js, Vite, Astro and similar frameworks, REST and gRPC APIs, MCP servers, inference servers and ordinary background services. Custom commands and arguments, environment variables from Secrets, replica counts, resource limits, security contexts, volumes and health probes are all part of the Service definition.
Does it build images as well?
No. Octelium deploys and serves an image you have already built and pushed to a registry, public or private. Building and pushing stay in whatever CI you already use, and the pipeline that does it can itself be a workload User authorized to apply the change.
How is an application exposed?
Through the same Service that runs it. Publishing it makes the Cluster's internet-facing proxy serve it at its own public FQDN with a managed TLS certificate. It can require an identity from your own identity provider, or be anonymous and open to everyone, and an anonymous Service can still filter on path, method, headers and body content.
Can it use persistent storage?
Yes. Kubernetes volumes are mounted into managed containers, including persistent volume claims and emptyDir volumes with a size limit, with mount paths and read-only flags set per Service.
Do I need Kubernetes experience?
No. An Octelium Cluster runs on top of Kubernetes and uses it to schedule managed containers, but the interface you work with is Octelium's own resources. The quick installer provisions a complete single-node Cluster, including Kubernetes itself, on one fresh Linux VM with 2 vCPUs, 2 GB of RAM and a domain you own.
Is Octelium free and open source?
Yes. Octelium is designed for self-hosting and does not require a proprietary hosted control plane. The core platform is free and open source. An optional enterprise package adds capabilities for organizations with additional operational, identity-management and security requirements, including the web console, SCIM provisioning and Secret encryption at rest.
Get started

Run Octelium on your own infrastructure

Install a self-hosted Cluster and start applying identity-aware access control to your Services.