DEV Community

Cover image for Deploying Ory Keto - Open-Source Permission and Access Control Server
Sanskriti Harmukh for Vultr

Posted on with Aashish Chaurasiya Originally published at docs.vultr.com

Deploying Ory Keto - Open-Source Permission and Access Control Server

Ory Keto is an open-source permission and access control server that implements Relationship-Based Access Control (ReBAC) modeled on Google's Zanzibar authorization system. Instead of hardcoding access rules, Keto stores relationships as relation tuples and answers permission questions through a dedicated Application Programming Interface (API). This approach scales from direct ownership checks to complex nested permissions like group membership, without changing application code. This guide deploys Ory Keto on a Linux server using Docker Compose, PostgreSQL, and Traefik as a reverse proxy that provides automatic HTTPS through Let's Encrypt. It covers defining a permission model with the Ory Permission Language (OPL), binding Keto's read, write, and metrics APIs to the loopback interface, and exposing only a permission-enforcing application over HTTPS. By the end, you'll have a self-hosted Keto server where no permission API is reachable from the public internet.


Set Up the Directory Structure, Configuration, and Environment Variables

Ory Keto requires a project directory that holds the server configuration, the permission model, a small backend application, and an environment variables file. Database credentials stay in the environment variables file to keep them out of the main configuration.

1. Create the project directory with all required subdirectories:

$ mkdir -p ~/ory-keto/{config,reference-app,data/postgres,letsencrypt}
Enter fullscreen mode Exit fullscreen mode

The command creates four subdirectories:

  • config/: Stores the Keto configuration and the permission model.
  • reference-app/: Stores a Node.js backend that enforces permissions through the Keto read API.
  • data/postgres/: Persists PostgreSQL database files across container restarts.
  • letsencrypt/: Stores the Traefik ACME certificate files for automatic HTTPS renewal.

2. Navigate to the project directory:

$ cd ~/ory-keto
Enter fullscreen mode Exit fullscreen mode

3. Create the Keto configuration file:

$ nano config/keto.yml
Enter fullscreen mode Exit fullscreen mode

4. Add the following content:

namespaces:
  location: file:///etc/config/keto/namespaces.keto.ts

serve:
  read:
    host: 0.0.0.0
    port: 4466
  write:
    host: 0.0.0.0
    port: 4467
  metrics:
    host: 0.0.0.0
    port: 4468

log:
  level: info
  format: text
  leak_sensitive_values: false
Enter fullscreen mode Exit fullscreen mode

Save and close the file. The namespaces.location setting points Keto to the permission model inside the container, and the serve block defines the read, write, and metrics APIs. Each API binds to 0.0.0.0 inside the container so Docker can forward it. The loopback restriction is applied later in the Docker Compose port mapping.

Note: The Data Source Name (DSN) that connects Keto to PostgreSQL is not stored in this file. It is passed through the DSN environment variable in the Docker Compose stack to keep the database password out of the configuration file.

5. Create the permission model file:

$ nano config/namespaces.keto.ts
Enter fullscreen mode Exit fullscreen mode

6. Add the following content:

import { Namespace, SubjectSet, Context } from "@ory/keto-namespace-types"

class User implements Namespace {}

class Group implements Namespace {
  related: {
    members: User[]
  }
}

class Document implements Namespace {
  related: {
    owners: User[]
    viewers: (User | SubjectSet<Group, "members">)[]
  }

  permits = {
    view: (ctx: Context): boolean =>
      this.related.owners.includes(ctx.subject) ||
      this.related.viewers.includes(ctx.subject),

    edit: (ctx: Context): boolean =>
      this.related.owners.includes(ctx.subject),
  }
}
Enter fullscreen mode Exit fullscreen mode

Save and close the file. This model defines the User, Group, and Document namespaces. A document has owners and viewers, where viewers accepts either a direct user or a group's members, which grants an entire group view access with one relationship. The permits block grants view to owners and viewers, and edit to owners only. For the full syntax, see the Ory Permission Language reference.

7. Create the package descriptor for the backend resource server. This Node.js service demonstrates how an application calls the Keto read API instead of hardcoding access rules:

$ nano reference-app/package.json
Enter fullscreen mode Exit fullscreen mode

8. Add the following content:

{
  "name": "keto-file-service",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "start": "node server.js"
  },
  "dependencies": {
    "express": "5.2.1"
  }
}
Enter fullscreen mode Exit fullscreen mode

Save and close the file.

9. Create the backend service file:

$ nano reference-app/server.js
Enter fullscreen mode Exit fullscreen mode

10. Add the following content:

import express from "express"

const app = express()
const KETO_READ_URL = process.env.KETO_READ_URL || "http://keto:4466"
const PORT = process.env.PORT || 3000

// In a real application, file content lives in a database or object storage.
const files = {
  readme: "This is the README. Only its owner can view it.",
  roadmap: "Q3 roadmap: ship group-based sharing.",
}

async function canView(subjectId, documentId) {
  const url =
    `${KETO_READ_URL}/relation-tuples/check?namespace=Document` +
    `&object=${documentId}&relation=view&subject_id=${subjectId}`
  try {
    const response = await fetch(url)
    if (!response.ok) return false
    const body = await response.json()
    return body.allowed === true
  } catch (error) {
    console.error("Permission check failed:", error.message)
    return false
  }
}

app.get("/files/:id", async (req, res) => {
  const subjectId = req.header("X-User-Id")
  if (!subjectId) {
    return res.status(401).json({ error: "Missing X-User-Id header" })
  }

  const documentId = req.params.id
  const content = files[documentId]
  if (!content) {
    return res.status(404).json({ error: "File not found" })
  }

  const allowed = await canView(subjectId, documentId)
  if (!allowed) {
    return res.status(403).json({ error: "Forbidden" })
  }

  res.json({ id: documentId, content })
})

app.listen(PORT, () => {
  console.log(`File service listening on port ${PORT}`)
})
Enter fullscreen mode Exit fullscreen mode

Save and close the file. This Express backend is a policy enforcement point: on every request to /files/:id, it asks the Keto read API whether the subject can view the document and returns the content only when Keto reports "allowed": true. The X-User-Id header stands in for a subject already authenticated by an identity provider such as Ory Kratos or Ory Hydra. The canView function fails closed: if the read API is unreachable or returns an error, it denies access rather than leaking the file.

Note: The service receives only KETO_READ_URL and never credentials for the write API, so it can check permissions but cannot create or delete relationships.

11. Create the environment variables file:

$ nano .env
Enter fullscreen mode Exit fullscreen mode

12. Add the following content. Replace keto.example.com with your domain name, admin@example.com with your email address for Let's Encrypt, and EXAMPLE_DB_PASSWORD with a strong, unique database password:

KETO_VERSION=v26.2.0
DOMAIN=keto.example.com
LETSENCRYPT_EMAIL=admin@example.com
POSTGRES_USER=keto
POSTGRES_PASSWORD=EXAMPLE_DB_PASSWORD
POSTGRES_DB=ketodb
LOG_LEVEL=info
Enter fullscreen mode Exit fullscreen mode

Save and close the file.

13. Verify the complete directory structure:

$ find . -type f
Enter fullscreen mode Exit fullscreen mode

The output lists the following files:

./.env
./config/keto.yml
./config/namespaces.keto.ts
./reference-app/package.json
./reference-app/server.js
Enter fullscreen mode Exit fullscreen mode

Deploy with Docker Compose

Docker Compose orchestrates the Keto server, PostgreSQL database, backend application, and Traefik reverse proxy as a single deployment. Traefik obtains and renews a Let's Encrypt certificate automatically, so no manual certificate steps are required.

1. Create the Docker Compose file:

$ nano docker-compose.yml
Enter fullscreen mode Exit fullscreen mode

2. Add the following content:

services:
  traefik:
    image: traefik:v3.7.9
    command:
      - "--providers.docker=true"
      - "--providers.docker.exposedbydefault=false"
      - "--entrypoints.web.address=:80"
      - "--entrypoints.websecure.address=:443"
      - "--entrypoints.web.http.redirections.entrypoint.to=websecure"
      - "--entrypoints.web.http.redirections.entrypoint.scheme=https"
      - "--certificatesresolvers.letsencrypt.acme.httpchallenge=true"
      - "--certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web"
      - "--certificatesresolvers.letsencrypt.acme.email=${LETSENCRYPT_EMAIL}"
      - "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json"
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./letsencrypt:/letsencrypt
      - /var/run/docker.sock:/var/run/docker.sock:ro
    networks:
      - keto-network
    restart: unless-stopped

  postgres:
    image: postgres:18-alpine
    environment:
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_DB: ${POSTGRES_DB}
    volumes:
      - ./data/postgres:/var/lib/postgresql
    networks:
      - keto-network
    restart: unless-stopped
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
      interval: 5s
      timeout: 5s
      retries: 5
    deploy:
      resources:
        limits:
          cpus: '1.0'
          memory: 512M

  keto-migrate:
    image: oryd/keto:${KETO_VERSION}
    environment:
      - DSN=postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}?sslmode=disable
    command: migrate up --yes --config /etc/config/keto/keto.yml
    volumes:
      - ./config:/etc/config/keto
    depends_on:
      postgres:
        condition: service_healthy
    networks:
      - keto-network
    restart: on-failure
    deploy:
      resources:
        limits:
          cpus: '0.50'
          memory: 256M

  keto:
    image: oryd/keto:${KETO_VERSION}
    ports:
      - "127.0.0.1:4466:4466"
      - "127.0.0.1:4467:4467"
      - "127.0.0.1:4468:4468"
    environment:
      - DSN=postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}?sslmode=disable
      - LOG_LEVEL=${LOG_LEVEL}
    command: serve --config /etc/config/keto/keto.yml
    volumes:
      - ./config:/etc/config/keto
    depends_on:
      keto-migrate:
        condition: service_completed_successfully
    networks:
      - keto-network
    restart: unless-stopped
    deploy:
      resources:
        limits:
          cpus: '1.0'
          memory: 512M

  keto-app:
    build:
      context: ./reference-app
      dockerfile_inline: |
        FROM node:24-alpine
        WORKDIR /app
        COPY package*.json ./
        RUN npm install --omit=dev
        COPY . .
        CMD ["npm", "start"]
    environment:
      - KETO_READ_URL=http://keto:4466
    depends_on:
      - keto
    networks:
      - keto-network
    restart: unless-stopped
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.keto-app.rule=Host(`${DOMAIN}`) && PathPrefix(`/files`)"
      - "traefik.http.routers.keto-app.entrypoints=websecure"
      - "traefik.http.routers.keto-app.tls.certresolver=letsencrypt"
      - "traefik.http.services.keto-app.loadbalancer.server.port=3000"
    deploy:
      resources:
        limits:
          cpus: '0.50'
          memory: 256M

networks:
  keto-network:
    driver: bridge
Enter fullscreen mode Exit fullscreen mode

Save and close the file. Five services make up the stack:

  • traefik: The reverse proxy and TLS termination point. It listens on ports 80 and 443, redirects HTTP to HTTPS, and provisions Let's Encrypt certificates using the email in LETSENCRYPT_EMAIL. With exposedbydefault=false, it routes only containers that set traefik.enable=true, which is keto-app alone.
  • postgres: A PostgreSQL 18 database that stores relation tuples, with a health check that gates dependent services. PostgreSQL 18 images store the cluster in a version-specific subdirectory, so the volume mounts at /var/lib/postgresql rather than the /var/lib/postgresql/data path used by earlier versions.
  • keto-migrate: A one-time service that runs database migrations, then exits with code 0. The keto service waits for it to complete before starting.
  • keto: The main Keto server. Its read (4466), write (4467), and metrics (4468) ports are all bound to 127.0.0.1, so no API is reachable from outside the server. It carries no Traefik labels, so Traefik never exposes it. Other containers on keto-network still reach the read API at http://keto:4466, which is how the application performs checks.
  • keto-app: The Node.js backend, built inline from a Node 24 Alpine image. The Traefik labels route only Host(keto.example.com) requests under the /files path to it on port 3000, making it the single public entry point.

Most services also set deploy.resources.limits to cap CPU and memory usage.

Note: The PostgreSQL DSN uses sslmode=disable because the connection travels over the internal Docker bridge network. If you move PostgreSQL to a separate host, change sslmode=disable to sslmode=require and configure TLS on the database server.

3. Start all services in detached mode:

$ docker compose up -d
Enter fullscreen mode Exit fullscreen mode

Wait about 30 seconds for the database to initialize and migrations to complete.

4. Check the status of all containers:

$ docker compose ps -a
Enter fullscreen mode Exit fullscreen mode

The output looks similar to the following. The keto-migrate container shows Exited (0), and all other containers show Up.

NAME                      IMAGE                    STATUS
ory-keto-traefik-1        traefik:v3.7.9           Up
ory-keto-postgres-1       postgres:18-alpine       Up (healthy)
ory-keto-keto-migrate-1   oryd/keto:v26.2.0        Exited (0)
ory-keto-keto-1           oryd/keto:v26.2.0        Up
ory-keto-keto-app-1       ory-keto-keto-app        Up
Enter fullscreen mode Exit fullscreen mode

5. Confirm the server started by checking the write API health endpoint on the loopback interface:

$ curl -s http://127.0.0.1:4467/health/ready
Enter fullscreen mode Exit fullscreen mode

A successful response returns {"status":"ok"}. If it fails, the database may still be initializing; wait a few seconds and run docker compose restart keto.


Access and Configure Ory Keto

These checks confirm that Keto's APIs stay private on the loopback interface and that Traefik exposes only the enforcement application over HTTPS. You then configure a firewall to restrict inbound traffic.

1. Verify that the read API is healthy on the loopback interface:

$ curl -s http://127.0.0.1:4466/health/ready
Enter fullscreen mode Exit fullscreen mode

A successful response returns {"status":"ok"}.

2. Confirm the public application responds over HTTPS. Replace keto.example.com with your domain. Traefik obtains the certificate on the first HTTPS request, which can take a few seconds. Because the request omits the X-User-Id header, the backend returns 401, which confirms that Traefik terminates TLS and routes to the application:

$ curl -s -o /dev/null -w "%{http_code}\n" https://keto.example.com/files/readme
Enter fullscreen mode Exit fullscreen mode

The output is 401.

3. Verify that the metrics endpoint is accessible on the loopback interface:

$ curl -s http://127.0.0.1:4468/metrics/prometheus | head -n 5
Enter fullscreen mode Exit fullscreen mode

The output contains Prometheus-formatted metrics that a monitoring system can scrape.

4. Allow Secure Shell (SSH) traffic through the Uncomplicated Firewall (UFW) so you keep remote access after enabling the firewall:

$ sudo ufw allow 22/tcp
Enter fullscreen mode Exit fullscreen mode

5. Allow HTTP traffic on port 80:

$ sudo ufw allow 80/tcp
Enter fullscreen mode Exit fullscreen mode

6. Allow HTTPS traffic on port 443:

$ sudo ufw allow 443/tcp
Enter fullscreen mode Exit fullscreen mode

Warning: Docker modifies iptables rules directly, which can bypass UFW. In this deployment, only the Traefik container publishes ports to all interfaces (80 and 443), and Keto's read, write, and metrics APIs bind to 127.0.0.1. Even if Docker bypasses UFW, no Keto API is exposed on a public interface.

7. Enable UFW. When prompted, type y to confirm:

$ sudo ufw enable
Enter fullscreen mode Exit fullscreen mode

8. Verify the firewall rules:

$ sudo ufw status
Enter fullscreen mode Exit fullscreen mode

The output lists ports 22/tcp, 80/tcp, and 443/tcp with an ALLOW action for both IPv4 and IPv6.

9. Confirm the read API is not reachable from outside the server. From your local machine, replace YOUR_SERVER_IP with your server's IP address:

$ curl -s --connect-timeout 5 http://YOUR_SERVER_IP:4466/health/ready
Enter fullscreen mode Exit fullscreen mode

The request times out or is refused.

10. Confirm the write API is not reachable either:

$ curl -s --connect-timeout 5 http://YOUR_SERVER_IP:4467/health/ready
Enter fullscreen mode Exit fullscreen mode

The request times out or is refused, confirming that neither the read API nor the write API is reachable from the internet.


Demonstrate Application Use Case by Performing Actions

With Keto running, you populate the permission model with relation tuples through the write API, evaluate permissions through the read API, and confirm that the backend application enforces those permissions. Because the read and write APIs bind to the loopback interface, run the management and check commands on the server. For the full endpoint reference, see the Ory Keto REST API documentation.

Create Relationships

1. Send a batch of relation tuples to the write API on 127.0.0.1:4467:

$ curl -s -X PATCH http://127.0.0.1:4467/admin/relation-tuples \
    -H 'Content-Type: application/json' \
    -w "\nHTTP %{http_code}\n" \
    -d '[
      {
        "action": "insert",
        "relation_tuple": {
          "namespace": "Document",
          "object": "readme",
          "relation": "owners",
          "subject_id": "alice"
        }
      },
      {
        "action": "insert",
        "relation_tuple": {
          "namespace": "Group",
          "object": "engineering",
          "relation": "members",
          "subject_id": "bob"
        }
      },
      {
        "action": "insert",
        "relation_tuple": {
          "namespace": "Document",
          "object": "roadmap",
          "relation": "viewers",
          "subject_set": {
            "namespace": "Group",
            "object": "engineering",
            "relation": "members"
          }
        }
      }
    ]'
Enter fullscreen mode Exit fullscreen mode

The command inserts three relationships: alice owns the readme document, bob is a member of the engineering group, and the engineering group's members are viewers of the roadmap document. On success, the write API returns an empty body with HTTP status 204 No Content.

2. Confirm the tuples were stored by listing the Document namespace through the read API:

$ curl -s "http://127.0.0.1:4466/relation-tuples?namespace=Document"
Enter fullscreen mode Exit fullscreen mode

The response lists the readme owner and the roadmap viewer subject set.

Check Permissions Directly

1. Check whether alice can view the readme document:

$ curl -s "http://127.0.0.1:4466/relation-tuples/check?namespace=Document&object=readme&relation=view&subject_id=alice"
Enter fullscreen mode Exit fullscreen mode

alice is an owner, so the response returns {"allowed":true}.

2. Check whether bob can view the roadmap document. bob has no direct relationship to roadmap, but is a member of the engineering group, which is a viewer:

$ curl -s "http://127.0.0.1:4466/relation-tuples/check?namespace=Document&object=roadmap&relation=view&subject_id=bob"
Enter fullscreen mode Exit fullscreen mode

Keto resolves the subject set and returns {"allowed":true}. This is the core strength of ReBAC: a single group membership grants access without a direct relationship to the document.

3. Check whether bob can edit the roadmap document. bob is a viewer through group membership, but viewers cannot edit:

$ curl -s "http://127.0.0.1:4466/relation-tuples/check?namespace=Document&object=roadmap&relation=edit&subject_id=bob"
Enter fullscreen mode Exit fullscreen mode

The edit permission requires ownership, so the response returns {"allowed":false}.

4. Inspect the full set of subjects that hold the viewers relation on the roadmap document:

$ curl -s "http://127.0.0.1:4466/relation-tuples/expand?namespace=Document&object=roadmap&relation=viewers&max-depth=3"
Enter fullscreen mode Exit fullscreen mode

The response contains a nested JSON tree with the engineering group's members subject set as a child node, which helps you audit who has access and why.

Enforce Permissions Through the Application

The keto-app service is the only publicly exposed component, and it reuses the relationships created earlier. These requests confirm that a real backend enforces the permission model through the Keto read API instead of embedding access rules in code. Replace keto.example.com with your domain.

1. Request the readme document as alice, who owns it:

$ curl -s -H "X-User-Id: alice" https://keto.example.com/files/readme
Enter fullscreen mode Exit fullscreen mode

Keto confirms that alice is an owner, so the backend returns the file content:

{"id":"readme","content":"This is the README. Only its owner can view it."}
Enter fullscreen mode Exit fullscreen mode

2. Request the roadmap document as bob, who inherits view access through the engineering group:

$ curl -s -H "X-User-Id: bob" https://keto.example.com/files/roadmap
Enter fullscreen mode Exit fullscreen mode

The response returns the file content, confirming that the backend enforces group-inherited permissions even though server.js contains no group-specific logic.

3. Request the roadmap document as carol, who has no relationship to the document or the group:

$ curl -s -i -H "X-User-Id: carol" https://keto.example.com/files/roadmap
Enter fullscreen mode Exit fullscreen mode

The response returns 403 Forbidden. Because the permission logic lives entirely in the OPL model and the Keto database, changing who can view roadmap never requires modifying or redeploying server.js.

4. Request a file without the X-User-Id header:

$ curl -s -i https://keto.example.com/files/readme
Enter fullscreen mode Exit fullscreen mode

The response returns 401 Unauthorized. In production, an upstream identity provider or API gateway sets this header only after verifying the caller's identity.


Next Steps

Ory Keto is running with its APIs private on the loopback interface and only a permission-enforcing application exposed over HTTPS. From here you can:

  • Expand the permission model with additional namespaces and relations for your own application's resources
  • Integrate Keto's read API into your production backend as the enforcement point for every protected route
  • Pair Keto with an identity provider such as Ory Kratos or Ory Hydra to authenticate the subjects behind each permission check

For the full guide with additional tips, visit the original article on Vultr Docs.

Top comments (0)