Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions .github/workflows/image.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Builds the UI image (also used for the hydra-clients Job) for the Kubernetes deployment.
# Tag = full commit SHA: bulbashenko-com/infra pins the same SHA for the configs and the image.
name: image

on:
push:
branches: [main]
workflow_dispatch:

permissions:
contents: read
packages: write

jobs:
ui:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v5
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v6
with:
context: ui
push: true
tags: |
ghcr.io/${{ github.repository_owner }}/auth-ui:${{ github.sha }}
ghcr.io/${{ github.repository_owner }}/auth-ui:main
labels: |
org.opencontainers.image.source=https://github.com/${{ github.repository }}
org.opencontainers.image.revision=${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,12 @@ Groups decide access. Each OIDC client names one group in `metadata.required_gro
| `mail-users` | Mail clients and the Stalwart web UI |
| `vpn-users` | Joining the VPN mesh (Headscale) |

## Deploy on Kubernetes

`kustomization.yaml` at the root is a Kustomize base built from the same `ory/` files that `compose.yml` mounts: Deployments for Kratos, Hydra and Keto (each runs its migration as an init container), Oathkeeper and the UI, the `hydra-clients` Job, and Traefik routes (`auth-public` on the `web` entrypoint, `auth-admin` for `users.int` behind the mesh and Oathkeeper). Service names match the compose stack, so the configs' internal URLs work unchanged.

The deploying side provides the namespace, the image tag and a Secret `auth` with `KRATOS_DSN`, `HYDRA_DSN`, `KETO_DSN`, `KRATOS_SECRETS_{COOKIE,DEFAULT,CIPHER,PAGINATION}`, `HYDRA_SECRETS_{SYSTEM,COOKIE,PAGINATION}`, `SMTP_CONNECTION_URI`, `SMTP_SENDER` and `OIDC_{SITE,COOLIFY,HEADSCALE}_CLIENT_SECRET`. bulbashenko.com deploys it from [bulbashenko-com/infra](https://github.com/bulbashenko-com/infra), pinned to one commit of this repo; `.github/workflows/image.yml` builds `ghcr.io/bulbashenko-com/auth-ui:<commit>` for that commit.

## Deploy on Coolify

1. **Databases.** In the shared Postgres, create three databases, each with its own role:
Expand Down
277 changes: 277 additions & 0 deletions deploy/k8s/ory.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,277 @@
# Ory services. Configs come from ConfigMaps generated from ory/ (see the root kustomization.yaml);
# DSNs and secrets from the Secret `auth`, which the deploying repo (bulbashenko-com/infra) provides.
# Service names match the compose stack, so the configs' internal URLs (kratos:4433, hydra:4445, keto:4466) still work.
apiVersion: apps/v1
kind: Deployment
metadata:
name: kratos
spec:
replicas: 1
selector:
matchLabels: { app: kratos }
template:
metadata:
labels: { app: kratos }
spec:
securityContext:
runAsNonRoot: true
seccompProfile: { type: RuntimeDefault }
initContainers:
- name: migrate
image: oryd/kratos:v26.2.0
args: ["migrate", "sql", "-e", "--yes", "-c", "/etc/config/kratos/kratos.yml"]
env:
- { name: SQA_OPT_OUT, value: "true" }
- { name: DSN, valueFrom: { secretKeyRef: { name: auth, key: KRATOS_DSN } } }
volumeMounts:
- { name: config, mountPath: /etc/config/kratos }
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities: { drop: [ALL] }
containers:
- name: kratos
image: oryd/kratos:v26.2.0
args: ["serve", "-c", "/etc/config/kratos/kratos.yml", "--watch-courier"]
env:
- { name: SQA_OPT_OUT, value: "true" }
- { name: LOG_LEVEL, value: info }
- { name: DSN, valueFrom: { secretKeyRef: { name: auth, key: KRATOS_DSN } } }
- { name: SECRETS_COOKIE, valueFrom: { secretKeyRef: { name: auth, key: KRATOS_SECRETS_COOKIE } } }
- { name: SECRETS_DEFAULT, valueFrom: { secretKeyRef: { name: auth, key: KRATOS_SECRETS_DEFAULT } } }
- { name: SECRETS_CIPHER, valueFrom: { secretKeyRef: { name: auth, key: KRATOS_SECRETS_CIPHER } } }
- { name: SECRETS_PAGINATION, valueFrom: { secretKeyRef: { name: auth, key: KRATOS_SECRETS_PAGINATION } } }
- { name: COURIER_SMTP_CONNECTION_URI, valueFrom: { secretKeyRef: { name: auth, key: SMTP_CONNECTION_URI } } }
- { name: COURIER_SMTP_FROM_ADDRESS, valueFrom: { secretKeyRef: { name: auth, key: SMTP_SENDER } } }
ports:
- { name: public, containerPort: 4433 }
- { name: admin, containerPort: 4434 }
readinessProbe:
httpGet: { path: /admin/health/ready, port: admin }
livenessProbe:
httpGet: { path: /admin/health/alive, port: admin }
resources:
requests: { cpu: 10m, memory: 48Mi }
limits: { memory: 256Mi }
volumeMounts:
- { name: config, mountPath: /etc/config/kratos }
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities: { drop: [ALL] }
volumes:
- name: config
projected:
sources:
- configMap: { name: kratos-config }
- configMap:
name: kratos-courier-templates
items:
- { key: recovery_code.invalid.email.body.gotmpl, path: courier-templates/recovery_code/invalid/email.body.gotmpl }
- { key: recovery_code.invalid.email.body.plaintext.gotmpl, path: courier-templates/recovery_code/invalid/email.body.plaintext.gotmpl }
- { key: recovery_code.invalid.email.subject.gotmpl, path: courier-templates/recovery_code/invalid/email.subject.gotmpl }
- { key: recovery_code.valid.email.body.gotmpl, path: courier-templates/recovery_code/valid/email.body.gotmpl }
- { key: recovery_code.valid.email.body.plaintext.gotmpl, path: courier-templates/recovery_code/valid/email.body.plaintext.gotmpl }
- { key: recovery_code.valid.email.subject.gotmpl, path: courier-templates/recovery_code/valid/email.subject.gotmpl }
- { key: verification_code.invalid.email.body.gotmpl, path: courier-templates/verification_code/invalid/email.body.gotmpl }
- { key: verification_code.invalid.email.body.plaintext.gotmpl, path: courier-templates/verification_code/invalid/email.body.plaintext.gotmpl }
- { key: verification_code.invalid.email.subject.gotmpl, path: courier-templates/verification_code/invalid/email.subject.gotmpl }
- { key: verification_code.valid.email.body.gotmpl, path: courier-templates/verification_code/valid/email.body.gotmpl }
- { key: verification_code.valid.email.body.plaintext.gotmpl, path: courier-templates/verification_code/valid/email.body.plaintext.gotmpl }
- { key: verification_code.valid.email.subject.gotmpl, path: courier-templates/verification_code/valid/email.subject.gotmpl }
---
apiVersion: v1
kind: Service
metadata:
name: kratos
spec:
selector: { app: kratos }
ports:
- { name: public, port: 4433, targetPort: public }
- { name: admin, port: 4434, targetPort: admin }
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: hydra
spec:
replicas: 1
selector:
matchLabels: { app: hydra }
template:
metadata:
labels: { app: hydra }
spec:
securityContext:
runAsNonRoot: true
seccompProfile: { type: RuntimeDefault }
initContainers:
- name: migrate
image: oryd/hydra:v26.2.0
args: ["migrate", "sql", "up", "-e", "--yes", "-c", "/etc/config/hydra/hydra.yml"]
env:
- { name: SQA_OPT_OUT, value: "true" }
- { name: DSN, valueFrom: { secretKeyRef: { name: auth, key: HYDRA_DSN } } }
volumeMounts:
- { name: config, mountPath: /etc/config/hydra }
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities: { drop: [ALL] }
containers:
- name: hydra
image: oryd/hydra:v26.2.0
args: ["serve", "all", "-c", "/etc/config/hydra/hydra.yml"]
env:
- { name: SQA_OPT_OUT, value: "true" }
- { name: LOG_LEVEL, value: info }
- { name: DSN, valueFrom: { secretKeyRef: { name: auth, key: HYDRA_DSN } } }
- { name: SECRETS_SYSTEM, valueFrom: { secretKeyRef: { name: auth, key: HYDRA_SECRETS_SYSTEM } } }
- { name: SECRETS_COOKIE, valueFrom: { secretKeyRef: { name: auth, key: HYDRA_SECRETS_COOKIE } } }
- { name: SECRETS_PAGINATION, valueFrom: { secretKeyRef: { name: auth, key: HYDRA_SECRETS_PAGINATION } } }
ports:
- { name: public, containerPort: 4444 }
- { name: admin, containerPort: 4445 }
readinessProbe:
httpGet: { path: /health/ready, port: admin }
livenessProbe:
httpGet: { path: /health/alive, port: admin }
resources:
requests: { cpu: 10m, memory: 48Mi }
limits: { memory: 256Mi }
volumeMounts:
- { name: config, mountPath: /etc/config/hydra }
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities: { drop: [ALL] }
volumes:
- name: config
configMap: { name: hydra-config }
---
apiVersion: v1
kind: Service
metadata:
name: hydra
spec:
selector: { app: hydra }
ports:
- { name: public, port: 4444, targetPort: public }
- { name: admin, port: 4445, targetPort: admin }
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: keto
spec:
replicas: 1
selector:
matchLabels: { app: keto }
template:
metadata:
labels: { app: keto }
spec:
securityContext:
runAsNonRoot: true
seccompProfile: { type: RuntimeDefault }
initContainers:
- name: migrate
image: oryd/keto:v26.2.0
args: ["migrate", "up", "--yes", "-c", "/etc/config/keto/keto.yml"]
env:
- { name: SQA_OPT_OUT, value: "true" }
- { name: DSN, valueFrom: { secretKeyRef: { name: auth, key: KETO_DSN } } }
volumeMounts:
- { name: config, mountPath: /etc/config/keto }
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities: { drop: [ALL] }
containers:
- name: keto
image: oryd/keto:v26.2.0
args: ["serve", "-c", "/etc/config/keto/keto.yml"]
env:
- { name: SQA_OPT_OUT, value: "true" }
- { name: LOG_LEVEL, value: info }
- { name: DSN, valueFrom: { secretKeyRef: { name: auth, key: KETO_DSN } } }
ports:
- { name: read, containerPort: 4466 }
- { name: write, containerPort: 4467 }
readinessProbe:
httpGet: { path: /health/ready, port: read }
livenessProbe:
httpGet: { path: /health/alive, port: read }
resources:
requests: { cpu: 10m, memory: 32Mi }
limits: { memory: 192Mi }
volumeMounts:
- { name: config, mountPath: /etc/config/keto }
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities: { drop: [ALL] }
volumes:
- name: config
configMap: { name: keto-config }
---
apiVersion: v1
kind: Service
metadata:
name: keto
spec:
selector: { app: keto }
ports:
- { name: read, port: 4466, targetPort: read }
- { name: write, port: 4467, targetPort: write }
---
# Traefik forward-auth for admin UIs: GET http://oathkeeper.<ns>.svc:4456/decisions. No database.
apiVersion: apps/v1
kind: Deployment
metadata:
name: oathkeeper
spec:
replicas: 1
selector:
matchLabels: { app: oathkeeper }
template:
metadata:
labels: { app: oathkeeper }
spec:
securityContext:
runAsNonRoot: true
seccompProfile: { type: RuntimeDefault }
containers:
- name: oathkeeper
image: oryd/oathkeeper:v26.2.0
args: ["serve", "-c", "/etc/config/oathkeeper/oathkeeper.yml"]
env:
- { name: SQA_OPT_OUT, value: "true" }
- { name: LOG_LEVEL, value: info }
ports:
- { name: api, containerPort: 4456 }
readinessProbe:
httpGet: { path: /health/ready, port: api }
livenessProbe:
httpGet: { path: /health/alive, port: api }
resources:
requests: { cpu: 10m, memory: 24Mi }
limits: { memory: 128Mi }
volumeMounts:
- { name: config, mountPath: /etc/config/oathkeeper }
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities: { drop: [ALL] }
volumes:
- name: config
configMap: { name: oathkeeper-config }
---
apiVersion: v1
kind: Service
metadata:
name: oathkeeper
spec:
selector: { app: oathkeeper }
ports:
- { name: api, port: 4456, targetPort: api }
Loading