Kubernetes¶
Two manifests deploy everything in the secreview namespace:
examples/kubernetes/postgres.yaml
(PostgreSQL, official image) and
examples/kubernetes/security-review.yaml
(controller and MCP server, published image ghcr.io/cyr-ius/security-review:latest).
Deployment¶
The block that deploys everything, with the PostgreSQL of postgres.yaml, is in
Deploying the server.
With an existing PostgreSQL: a managed database (Amazon RDS, Azure Database for PostgreSQL, Google Cloud SQL...) or an
operator (CloudNativePG...) holding an empty secreview database owned by a secreview
role. Replace <password> and <host>; add ?sslmode=verify-full to the URL when the
server offers TLS.
kubectl create namespace secreview
kubectl -n secreview create secret generic secreview-db \
--from-literal=SECREVIEW_DATABASE_URL='postgresql+psycopg2://secreview:<password>@<host>:5432/secreview'
kubectl -n secreview create secret generic secreview-controller \
--from-literal=SECREVIEW_SECRET_KEY="$(openssl rand -hex 32)"
kubectl -n secreview apply -f https://raw.githubusercontent.com/cyr-ius/security-review/v5.0.0/examples/kubernetes/security-review.yaml
kubectl -n secreview rollout status deploy/secreview-controller
kubectl -n secreview rollout status deploy/secreview-mcp
kubectl -n secreview exec deploy/secreview-controller -c controller -- \
security-review-controller user reset-password admin
Content of the manifests¶
postgres.yaml contains a secreview-postgres StatefulSet (official image
postgres:18-alpine, one instance, 10 GiB volume, non-root container with a read-only file
system), its Service and a NetworkPolicy that only lets the pods of security-review-mcp
reach the port 5432. For production, prefer a managed database or an operator, which handle
replication, backups and upgrades.
security-review.yaml contains:
- a
secreview-controllerDeployment (one replica,Recreatestrategy) whose init container runssecurity-review-controller db upgradeat every deployment; - a
secreview-mcpDeployment, which only checks the schema when it starts; - a
secreview-configConfigMap shared by both processes; - one Service per process,
readinessandlivenessprobes on/healthz, non-root containers with a read-only file system, without any Linux capability; - a NetworkPolicy that limits access to the controller to the pods of the namespace and to the ingress controller (adapt its label).
The controller also reads the secreview-controller Secret, created by hand (see
Secrets), with SECREVIEW_SECRET_KEY: without it, the controller draws a random
session secret at every start, so the web sessions would end at every restart and would not be
shared by the replicas (see Login and sessions).
Content of the manifests of version v5.0.0, applied by these commands:
# Single-instance PostgreSQL for security-review-mcp, with the official image (postgres:18-alpine).
# For production, prefer a managed database or an operator (CloudNativePG...) that handles
# replication, backups and upgrades.
#
# kubectl -n secreview create secret generic secreview-postgres \
# --from-literal=POSTGRES_PASSWORD="$(openssl rand -hex 24)"
# kubectl -n secreview apply -f examples/kubernetes/postgres.yaml
# kubectl -n secreview create secret generic secreview-db \
# --from-literal=SECREVIEW_DATABASE_URL="postgresql+psycopg2://secreview:$(kubectl -n secreview \
# get secret secreview-postgres -o jsonpath='{.data.POSTGRES_PASSWORD}' | base64 -d)@secreview-postgres:5432/secreview"
#
# The Secret is not in this file, so that applying it never replaces the password.
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: secreview-postgres
labels:
app.kubernetes.io/name: postgresql
app.kubernetes.io/part-of: security-review-mcp
spec:
serviceName: secreview-postgres
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: postgresql
app.kubernetes.io/part-of: security-review-mcp
template:
metadata:
labels:
app.kubernetes.io/name: postgresql
app.kubernetes.io/part-of: security-review-mcp
spec:
# uid and gid of the postgres user of the Alpine image
securityContext:
runAsNonRoot: true
runAsUser: 70
runAsGroup: 70
fsGroup: 70
seccompProfile:
type: RuntimeDefault
automountServiceAccountToken: false
containers:
- name: postgres
image: postgres:18-alpine
env:
- name: POSTGRES_DB
value: secreview
- name: POSTGRES_USER
value: secreview
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: secreview-postgres
key: POSTGRES_PASSWORD
ports:
- name: postgres
containerPort: 5432
readinessProbe: &probe
exec:
command: ["pg_isready", "-U", "secreview", "-d", "secreview"]
periodSeconds: 10
livenessProbe: *probe
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
memory: 1Gi
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
volumeMounts:
# Since PostgreSQL 18, the image keeps its data under /var/lib/postgresql/18/docker
- name: data
mountPath: /var/lib/postgresql
- name: run
mountPath: /var/run/postgresql
- name: tmp
mountPath: /tmp
volumes:
- name: run
emptyDir: {}
- name: tmp
emptyDir: {}
volumeClaimTemplates:
- metadata:
name: data
spec:
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 10Gi
---
apiVersion: v1
kind: Service
metadata:
name: secreview-postgres
spec:
selector:
app.kubernetes.io/name: postgresql
app.kubernetes.io/part-of: security-review-mcp
ports:
- name: postgres
port: 5432
targetPort: postgres
---
# Only the pods of security-review-mcp (controller, MCP server) reach the database
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: secreview-postgres
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: postgresql
app.kubernetes.io/part-of: security-review-mcp
policyTypes: ["Ingress"]
ingress:
- from:
- podSelector:
matchLabels:
app.kubernetes.io/name: security-review-mcp
ports:
- port: 5432
# security-review-mcp on Kubernetes: the MCP server and the controller in two Deployments
# sharing a PostgreSQL database (not included: examples/kubernetes/postgres.yaml deploys the
# official image, or use your managed or operator-backed instance).
#
# kubectl create namespace secreview
# kubectl -n secreview create secret generic secreview-db \
# --from-literal=SECREVIEW_DATABASE_URL='postgresql+psycopg2://secreview:<password>@<host>:5432/secreview'
# kubectl -n secreview create secret generic secreview-controller \
# --from-literal=SECREVIEW_SECRET_KEY="$(openssl rand -hex 32)"
# kubectl -n secreview apply -f examples/kubernetes/security-review.yaml
# kubectl -n secreview rollout status deploy/secreview-controller
# kubectl -n secreview exec deploy/secreview-controller -c controller -- \
# security-review-controller user reset-password admin # initial admin password
#
# The published image contains the PostgreSQL driver; pin a version (X.Y.Z) in production.
#
# The controller migrates the schema in an init container (Recreate strategy: the old controller
# pods stop before the new schema is applied; concurrent migrations wait for each other on a
# PostgreSQL lock). The MCP pods only check the schema when they start: new ones restart until
# the controller has migrated it. Expose each Service through its own Ingress with TLS: the MCP
# server to the agents, the controller to the people (web interface, personal MCP tokens) and
# to the CI (security-review-ci calls the REST API of the controller).
#
# The secreview-controller Secret gives SECREVIEW_SECRET_KEY: without it, the controller draws
# a random session secret at every start (sessions end at restart and are not shared by the
# replicas). Put the proxy credentials there too. The Secrets are not in this file, so that
# applying it never replaces them; as manifests (keep them out of git, or encrypted):
#
# apiVersion: v1
# kind: Secret
# metadata:
# name: secreview-controller
# type: Opaque
# stringData:
# SECREVIEW_SECRET_KEY: <openssl rand -hex 32>
---
apiVersion: v1
kind: ConfigMap
metadata:
name: secreview-config
data:
SECREVIEW_LOG_LEVEL: INFO
# Only the init container of the controller migrates the schema
SECREVIEW_DB_AUTO_UPGRADE: "false"
# Address range of the ingress controller pods: their X-Forwarded-For gives the client
# address (login throttling, enrollment limits, logs). Adapt it to your cluster.
# SECREVIEW_FORWARDED_ALLOW_IPS: "10.0.0.0/8"
# Public URL of the web interface, given by the MCP server to the clients without a valid
# token (enrollment, 401) so that the user knows where to create a personal MCP token
# SECREVIEW_WEB_URL: https://secreview.example.com/ui
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: secreview-controller
labels:
app.kubernetes.io/name: security-review-mcp
app.kubernetes.io/component: controller
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app.kubernetes.io/name: security-review-mcp
app.kubernetes.io/component: controller
template:
metadata:
labels:
app.kubernetes.io/name: security-review-mcp
app.kubernetes.io/component: controller
spec:
securityContext:
runAsNonRoot: true
runAsUser: 10001
runAsGroup: 10001
seccompProfile:
type: RuntimeDefault
automountServiceAccountToken: false
initContainers:
- name: migrate
image: ghcr.io/cyr-ius/security-review:latest
command: ["security-review-controller", "db", "upgrade"]
envFrom: &env
- configMapRef:
name: secreview-config
- secretRef:
name: secreview-db
securityContext: &container-security
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
volumeMounts: &mounts
- name: tmp
mountPath: /tmp
containers:
- name: controller
image: ghcr.io/cyr-ius/security-review:latest
# The image listens on every interface of the pod, port 8000
command: ["security-review-controller", "serve"]
envFrom:
- configMapRef:
name: secreview-config
- secretRef:
name: secreview-db
# SECREVIEW_SECRET_KEY (and the proxy variables when they hold credentials)
- secretRef:
name: secreview-controller
env:
# Swagger documentation of the REST API on /api/docs (disabled by default)
# - name: SECREVIEW_SWAGGER_ENABLED
# value: "true"
# Daily download of the OSV advisories (needs egress to
# osv-vulnerabilities.storage.googleapis.com or a mirror);
# put credentials of the proxy in the secreview-controller Secret rather than here
# - name: SECREVIEW_OSV_AUTO_UPDATE
# value: "true"
# - name: SECREVIEW_OSV_ECOSYSTEMS
# value: PyPI,npm,Maven
# Proxy of the OSV download and of the OIDC provider (standard variables)
# - name: HTTPS_PROXY
# value: http://proxy.example.com:3128
# - name: HTTP_PROXY # fallback when HTTPS_PROXY is unset
# value: http://proxy.example.com:3128
# - name: NO_PROXY
# value: .svc,.cluster.local
ports:
- name: http
containerPort: 8000
readinessProbe: &probe
httpGet:
path: /healthz
port: http
periodSeconds: 10
livenessProbe: *probe
resources:
requests:
cpu: 50m
memory: 128Mi
limits:
memory: 512Mi
securityContext: *container-security
volumeMounts: *mounts
volumes:
- name: tmp
emptyDir: {}
---
apiVersion: v1
kind: Service
metadata:
name: secreview-controller
spec:
selector:
app.kubernetes.io/name: security-review-mcp
app.kubernetes.io/component: controller
ports:
- name: http
port: 80
targetPort: http
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: secreview-mcp
labels:
app.kubernetes.io/name: security-review-mcp
app.kubernetes.io/component: mcp
spec:
# MCP sessions live in the memory of a pod: before adding replicas, make the Ingress route
# each session to the same pod (affinity on the Mcp-Session-Id header)
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: security-review-mcp
app.kubernetes.io/component: mcp
template:
metadata:
labels:
app.kubernetes.io/name: security-review-mcp
app.kubernetes.io/component: mcp
spec:
securityContext:
runAsNonRoot: true
runAsUser: 10001
runAsGroup: 10001
seccompProfile:
type: RuntimeDefault
automountServiceAccountToken: false
containers:
- name: mcp
image: ghcr.io/cyr-ius/security-review:latest
# The image runs `serve`: streamable HTTP on every interface of the pod, port 8000
envFrom:
- configMapRef:
name: secreview-config
- secretRef:
name: secreview-db
ports:
- name: http
containerPort: 8000
readinessProbe: &probe
httpGet:
path: /healthz
port: http
periodSeconds: 10
livenessProbe: *probe
resources:
requests:
cpu: 50m
memory: 128Mi
limits:
memory: 512Mi
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
volumeMounts:
- name: tmp
mountPath: /tmp
volumes:
- name: tmp
emptyDir: {}
---
apiVersion: v1
kind: Service
metadata:
name: secreview-mcp
spec:
selector:
app.kubernetes.io/name: security-review-mcp
app.kubernetes.io/component: mcp
ports:
- name: http
port: 80
targetPort: http
---
# Only the pods of the namespace and the ingress controller (adjust its label) reach the
# controller: people and the CI go through its Ingress. The MCP server stays reachable for the
# agents through its own Ingress.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: secreview-controller
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: security-review-mcp
app.kubernetes.io/component: controller
policyTypes: ["Ingress"]
ingress:
- from:
- podSelector: {}
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: ingress-nginx
ports:
- port: 8000
Secrets¶
The Secrets (secreview-postgres, secreview-db, secreview-controller) are not in the
examples, so that kubectl apply never replaces their values. The password of
secreview-postgres and the one of the URL of secreview-db must be the same. To declare them as manifests rather than with kubectl create secret (GitOps), use kind: Secret:
apiVersion: v1
kind: Secret
metadata:
name: secreview-postgres
namespace: secreview
type: Opaque
stringData:
POSTGRES_PASSWORD: <password>
---
apiVersion: v1
kind: Secret
metadata:
name: secreview-db
namespace: secreview
type: Opaque
stringData:
SECREVIEW_DATABASE_URL: postgresql+psycopg2://secreview:<password>@secreview-postgres:5432/secreview
---
apiVersion: v1
kind: Secret
metadata:
name: secreview-controller
namespace: secreview
type: Opaque
stringData:
# openssl rand -hex 32 (at least 32 characters, never changed between deployments)
SECREVIEW_SECRET_KEY: <64 hexadecimal characters>
# Proxy holding credentials (OSV download, OIDC), optional
# HTTPS_PROXY: http://user:<password>@proxy.example.com:3128
stringData takes the values in clear; Kubernetes stores them base64-encoded in data. Never
commit this file with real values: encrypt it (Sealed Secrets, SOPS) or let an operator such as
External Secrets create it from your vault. The pods read the Secrets at start-up: after a
change, restart them (kubectl -n secreview rollout restart deploy/secreview-controller).
Changing SECREVIEW_SECRET_KEY ends every web session.
Image, exposure and administration¶
The manifest uses the published image ghcr.io/cyr-ius/security-review:latest, which
contains the PostgreSQL driver: pin a version (X.Y.Z) in production. The manifests contain
no Ingress: expose each Service through its own Ingress with TLS.
| Service | Reached by | Ingress |
|---|---|---|
secreview-mcp |
the agents (MCP on /mcp, enrollment) |
to every developer workstation |
secreview-controller |
the people (web interface /ui, personal MCP and CI tokens) and the CI (security-review-ci calls /api/v1/ci) |
to the developers and to the CI runners; kubectl port-forward is enough for a first test |
Then, in the ConfigMap:
- set
SECREVIEW_WEB_URLto the public URL of the web interface (https://…/ui): the MCP server gives it to the clients without a valid token, so that the user knows where to create a personal MCP token; - set
SECREVIEW_FORWARDED_ALLOW_IPSto the address range of the ingress controller pods: otherwise every client shares their address for the login throttling, the rate limit and the enrollment limits.
The NetworkPolicy of the controller lets in the ingress controller of the ingress-nginx
namespace: adapt its label to yours. Administration commands run in the pod of the controller:
kubectl -n secreview exec deploy/secreview-controller -c controller -- \
security-review-controller token list
OSV update¶
For the daily update of the OSV advisories,
uncomment SECREVIEW_OSV_AUTO_UPDATE (and if needed SECREVIEW_OSV_ECOSYSTEMS, HTTPS_PROXY
and NO_PROXY, in the secreview-controller Secret if the proxy holds credentials) on the
controller container, and allow its egress to osv-vulnerabilities.storage.googleapis.com (SECREVIEW_OSV_URL),
your mirror or the proxy if an
egress NetworkPolicy applies. The MCP server needs no egress. The Swagger documentation of the
REST API (/api/docs) stays disabled unless you uncomment SECREVIEW_SWAGGER_ENABLED.
Several replicas¶
Several replicas of the MCP server
MCP sessions (streamable HTTP transport) are kept in memory by the pod that opened them.
Before raising replicas, configure the Ingress to send every request of a session to the
same pod (affinity on the Mcp-Session-Id header, or failing that cookie or address
affinity). The controller can have several replicas: their migrations wait for each other
(PostgreSQL advisory lock), and SECREVIEW_SECRET_KEY lets them share the web sessions.