Skip to content

Web UI (kopia server)

Kopia ships a built-in web UI, an HTML view of a repository’s snapshots, policies, sources, and tasks.

Kopiur exposes it declaratively. Set spec.server on a Repository, or on a ClusterRepository, and the operator runs kopia server start in a Deployment and puts a Service in front of it.

There is no enabled field. The presence of the spec.server block is what turns the UI on, and removing the block tears everything back down.

Kopiur creates the workload and the Service only. Routing that Service to the outside world, with an Ingress or an HTTPRoute, is your job. See Exposing the Service.

The UI is an interactive surface for a human. Reach for it when you want to:

  • Browse and verify snapshots, policies, and sources visually, without the kubectl plugin.
  • Restore one-off files through the UI: pick a snapshot, mount it, pull a file.
  • Give an operator a point-and-click view of a repository’s contents. Ideally make it read-only, so browsing can’t accidentally delete a backup.

You do not need it for normal operation. Scheduled backups, restores, and maintenance all run headless in short-lived mover Jobs, and the UI is never involved.

The server is a long-lived pod that holds the repository decryption key, as the warning below explains. So only run it where you actually want interactive access, and tear it down when you’re done.

Field Type Default What it does
auth externally-tagged enum (generate | secretRef | insecure) generate UI login. Leave it out and the operator generates credentials. It never defaults to no login.
readOnly bool false Read-only UI. Connects the repository read-only, so the UI cannot create, delete, or alter backups. It can browse and restore only. This is forced on when the Repository has spec.mode: ReadOnly.
service.type enum(ClusterIP|NodePort|LoadBalancer) ClusterIP How the Service is exposed. Routing outside the cluster is your job.
service.port int 51515 Listen + Service port.
service.annotations map — Applied to the Service. This is where you feed your ingress or load-balancer controller.
resources ResourceRequirements — Requests/limits for the server pod.
securityContext SecurityContext hardened default Override the default hardened container security context.
namespace string — Required, and only for ClusterRepository. It says which namespace the server objects land in, because a cluster-scoped owner has no namespace of its own.

There is no enabled field. The presence of spec.server means on, and its absence means off. See the full field reference.

spec.server is just a field on a Repository, so it deploys like any other CRD edit: kubectl apply, or through GitOps with Flux or Argo.

The smallest form adds the block to a repository and takes the safe defaults, which are operator-minted credentials and a ClusterIP Service:

deploy/examples/repository-server-ui-minimal.yaml
# Smallest web-UI form on a namespaced Repository (docs/src/content/docs/server.mdx)
#
# Adding a `spec.server` block turns the kopia web UI on (there is no `enabled`
# bool — presence is "on", absence is "off"). This is the minimal form: take the
# safe defaults (operator-minted credentials, ClusterIP Service, port 51515).
#
# The Secret named here must already exist in this namespace (the repository's
# backend auth + encryption password). For a complete server example with
# HTTPRoute/NetworkPolicy templates see deploy/examples/25-repository-server-ui.yaml.
#
# Field shapes verified against crates/api: externally-tagged backend
# (`backend.s3`), and `server.auth.generate` (externally-tagged auth enum).
---
apiVersion: kopiur.home-operations.com/v1alpha1
kind: Repository
metadata:
name: nas-primary
namespace: apps
spec:
backend:
s3:
{
bucket: my-backups,
endpoint: s3.amazonaws.com,
region: us-east-1,
auth: { secretRef: { name: nas-primary-creds } },
}
encryption:
passwordSecretRef: { name: nas-primary-creds, key: KOPIA_PASSWORD }
server:
auth: { generate: {} } # operator mints UI credentials into an owned Secret

Apply it like any other manifest, with kubectl apply -f or through GitOps.

Once the repository is Ready, the controller creates the objects below. They are all named <repo>-kopia-ui, and all labeled app.kubernetes.io/name=kopiur-server and app.kubernetes.io/instance=<repo>:

Object Name Purpose
Deployment <repo>-kopia-ui Runs kopia server start. One replica, the Recreate strategy, the mover image, and TCP readiness and liveness probes.
Service <repo>-kopia-ui Fronts the Deployment on the configured port.
ConfigMap <repo>-kopia-ui The server’s work spec (which repo, port, auth mode).
Secret <repo>-kopia-ui-auth generate mode only. Holds the minted UI credentials, under the keys username and password.
Terminal window
$ kubectl get deploy,svc,cm,secret -n apps \
-l app.kubernetes.io/name=kopiur-server,app.kubernetes.io/instance=nas-primary

The controller manages Deployments, Services, ConfigMaps, and Secrets for this feature.

For a namespaced Repository, the objects carry an ownerReference back to the repository. A ClusterRepository cleans them up with a finalizer instead. See ClusterRepository server.

How the server gets the repository credentials

Section titled “How the server gets the repository credentials”

The server pod receives every credential Secret the repository references as environment variables, through envFrom, exactly like a mover Job. That is the encryption-password Secret holding KOPIA_PASSWORD, plus the backend’s auth.secretRef Secret when it is a different one.

Keeping the password and the backend keys in separate Secrets is fully supported. The Deployment carries one envFrom entry per distinct Secret.

For a backend using auth.workloadIdentity, which S3, Azure, and GCS support, there is no backend Secret. The server pod runs as the workload-identity ServiceAccount instead.

That ServiceAccount must exist, with its cloud-federation annotations, in the namespace the server runs in. That is the repository’s namespace, or spec.server.namespace for a ClusterRepository.

Kopiur never creates that ServiceAccount. A missing one surfaces as an actionable error on the repository’s .status. Azure pods get the azure.workload.identity/use: "true" opt-in label automatically.

Turn on the UI from a clean slate and prove it answers, without leaving the cluster. You should get 200 with credentials and 401 without them.

It takes one apply-ready bundle, deploy/examples/tryit/server-ui.yaml. It contains the apps Namespace, the backend Secret, and a Repository named nas-primary with the minimal spec.server block.

apiVersion: kopiur.home-operations.com/v1alpha1
kind: Repository
metadata:
name: nas-primary
namespace: apps
spec:
backend:
s3:
bucket: my-kopia-bucket
endpoint: s3.us-east-1.amazonaws.com
region: us-east-1
auth:
secretRef:
name: nas-primary-creds
encryption:
passwordSecretRef:
name: nas-primary-creds
key: KOPIA_PASSWORD
create:
enabled: true
# ── Turn on the web UI ────────────────────────────────────────────────────────
# Presence of `spec.server` is "on" (there is no `enabled` bool). The minimal
# form takes the safe defaults: operator-minted credentials into an owned
# Secret, a ClusterIP Service, port 51515.
server:
auth: { generate: {} }

1. Fill in the credentials, meaning the AWS_* values and KOPIA_PASSWORD, in the secret section. Then apply the bundle and wait for the repository to be Ready. The server objects are created only once it is:

Terminal window
$ kubectl apply -f deploy/examples/tryit/server-ui.yaml
$ kubectl -n apps wait --for=condition=Ready repository/nas-primary --timeout=2m

2. Confirm the operator created the server objects. They are all named nas-primary-kopia-ui* and labeled for the instance:

Terminal window
$ kubectl -n apps get deploy,svc,secret \
-l app.kubernetes.io/name=kopiur-server,app.kubernetes.io/instance=nas-primary
NAME READY UP-TO-DATE AVAILABLE AGE
deployment.apps/nas-primary-kopia-ui 1/1 1 1 40s
NAME TYPE CLUSTER-IP PORT(S) AGE
service/nas-primary-kopia-ui ClusterIP 10.96.12.34 51515/TCP 40s
NAME TYPE DATA AGE
secret/nas-primary-kopia-ui-auth Opaque 2 40s

A 1/1 Deployment, a Service on 51515, and the nas-primary-kopia-ui-auth Secret, with its username and password keys, together mean the UI is up.

3. Read the minted credentials from that Secret:

Terminal window
$ kubectl -n apps get secret nas-primary-kopia-ui-auth \
-o jsonpath='{.data.username}' | base64 -d; echo
kopia
$ kubectl -n apps get secret nas-primary-kopia-ui-auth \
-o jsonpath='{.data.password}' | base64 -d; echo
<illustrative — your minted password>

4. Prove the UI answers. Port-forward the Service and curl it. You get 200 with the credentials and 401 without:

Terminal window
$ kubectl -n apps port-forward svc/nas-primary-kopia-ui 51515:51515 &
# with the credentials → 200:
$ curl -su 'kopia:<password>' http://localhost:51515/ -o /dev/null -w '%{http_code}\n'
200
# without them → 401 (the UI never defaults to no-auth):
$ curl -s http://localhost:51515/ -o /dev/null -w '%{http_code}\n'
401

The server speaks plain HTTP inside the pod, because the operator starts kopia with --insecure. TLS belongs at your ingress or load balancer, never on the raw Service.

Tear it down by removing the spec.server block and re-applying. The operator then deletes the Deployment, the Service, the ConfigMap, and the generated Secret it owns:

Terminal window
$ kubectl -n apps patch repository nas-primary --type merge -p '{"spec":{"server":null}}'

spec.server.auth is an externally-tagged enum, so you set exactly one of three keys. It defaults to generate, and never to “no login”.

Mode Shape When to use
generate (default) generate: { username? } Let the operator mint a random password. The simplest safe choice.
secretRef secretRef: { name, usernameKey, passwordKey } You manage the UI credentials yourself (e.g. a shared/SSO-fronted password).
insecure insecure: { acknowledgeInsecure: true } No login at all. Dangerous. For throwaway or lab use only.
Section titled “generate — operator-minted credentials (recommended)”

The operator creates a Secret named <repo>-kopia-ui-auth once, with the keys username and password. It pins the reference to it in status.server.generatedSecretRef, and never rotates it on later reconciles.

The username defaults to kopia. Set generate: { username: alice } to change it. Read the password with:

Terminal window
$ kubectl get secret nas-primary-kopia-ui-auth -n apps \
-o jsonpath='{.data.password}' | base64 -d; echo

Point at a Secret you own; all three keys are required:

server:
auth:
secretRef: { name: my-ui-creds, usernameKey: username, passwordKey: password }

This disables the UI login entirely. It demands an explicit acknowledgement, so you can’t end up here by accident:

server:
auth:
insecure: { acknowledgeInsecure: true } # required — the webhook rejects it otherwise

Set spec.server.readOnly: true and the operator connects the server’s repository read-only, using kopia repository connect --readonly, before starting the UI.

Every operation on that connection is then unable to change the repository, and so is everything the UI does. Creating, deleting, or altering snapshots, policies, and maintenance is rejected.

This is the right setting for a point-and-click browse and restore surface, where you never want a stray click to delete a backup.

server:
readOnly: true # the UI cannot mutate the repository (browse + restore only)
auth: { generate: {} }

The UI is read-only when spec.mode: ReadOnly is set or spec.server.readOnly: true is set:

  • A Repository with spec.mode: ReadOnly already serves restores only. Its UI is forced read-only, so you don’t need the field. Setting an explicit readOnly: false on such a repository is rejected by the webhook, because a read-only repository can’t serve a writable UI.
  • A normal ReadWrite repository, still taking backups through movers, gets a read-only UI by opting in with readOnly: true.

The reconciler pins the resolved value to status.server.readOnly. Here is a complete, apply-ready example: a ReadWrite repository with a read-only UI.

deploy/examples/26-repository-server-ui-readonly.yaml
# Example 26 — A read-only Kopia web UI (`spec.server.readOnly`)
#
# Same as example 25, but the server connects the repository READ-ONLY: the
# operator runs `kopia repository connect --readonly` before `kopia server start`,
# so creating, deleting, or altering backups through the UI is rejected by the
# repository. This is the safe choice when you want humans to BROWSE and RESTORE
# through the UI but never mutate backups.
#
# ┌─ WHAT read-only DOES AND DOESN'T DO ───────────────────────────────────────┐
# │ DOES: block create / delete / alter of snapshots, policies, maintenance │
# │ through the UI (the repository connection rejects every write). │
# │ DOESN'T: hide data — the server pod still holds the DECRYPTION KEY, so │
# │ anyone who reaches the UI can still READ and RESTORE every backup. │
# │ Read-only is not confidentiality: keep auth on and ClusterIP. │
# │ NOTE: kopia's UI does not grey out the (now non-functional) write/delete │
# │ buttons; the actions simply fail at the backend. │
# └─────────────────────────────────────────────────────────────────────────────┘
#
# Effective read-only is `spec.mode: ReadOnly` OR `spec.server.readOnly: true`.
# A `Repository` with `spec.mode: ReadOnly` already forces the UI read-only (you
# don't need the field); use `server.readOnly: true` to get a read-only UI on a
# normal ReadWrite repository that still takes backups via movers. The effective
# value is pinned to `status.server.readOnly`.
#
# Verified against crates/api: `server.readOnly` is `Option<bool>` on ServerSpec.
---
apiVersion: v1
kind: Secret
metadata:
name: nas-primary-creds
namespace: apps
type: Opaque
stringData:
AWS_ACCESS_KEY_ID: "REPLACE_ME"
AWS_SECRET_ACCESS_KEY: "REPLACE_ME"
KOPIA_PASSWORD: "choose-something-long-and-random"
---
apiVersion: kopiur.home-operations.com/v1alpha1
kind: Repository
metadata:
name: nas-primary
namespace: apps
spec:
# This repository still takes backups normally (mode defaults to ReadWrite);
# only the *UI* is read-only, via server.readOnly below.
backend:
s3:
bucket: my-backups
endpoint: s3.us-east-1.amazonaws.com
region: us-east-1
auth:
secretRef:
name: nas-primary-creds
encryption:
passwordSecretRef:
name: nas-primary-creds
key: KOPIA_PASSWORD
server:
readOnly: true # the UI cannot mutate the repository (browse + restore only)
auth:
generate:
username: kopia
service:
type: ClusterIP
port: 51515
annotations: {}

spec.server.service controls the Service; port defaults to 51515.

service.type Reach it from Notes
ClusterIP (default) inside the cluster Use kubectl port-forward or your own ingress. This is the safe default.
NodePort each node’s IP A static high port on every node.
LoadBalancer an external IP Provisioned by your cloud/LB controller.

Kopiur creates the Service only. It never creates an Ingress or an HTTPRoute.

Point your own router at the Service named <repo>-kopia-ui, on the configured port, and put TLS there, ideally with an extra authentication layer.

The full example carries commented HTTPRoute and NetworkPolicy templates you can adapt. Use service.annotations to feed your ingress or load-balancer controller, for instance an external-dns hostname or a load-balancer class.

For a quick look, port-forward the Service and open it locally:

Terminal window
$ kubectl port-forward -n apps svc/nas-primary-kopia-ui 51515:51515
# then browse http://localhost:51515 and log in with the credentials above

For ongoing access, route an Ingress or HTTPRoute to the Service, with TLS. Strongly consider a NetworkPolicy restricting who may reach it.

A ClusterRepository is cluster-scoped and has no namespace of its own, so its spec.server block requires a namespace. The other fields are identical:

deploy/examples/clusterrepository-server-ui.yaml
# Web UI on a cluster-scoped ClusterRepository (docs/src/content/docs/server.mdx)
#
# A ClusterRepository has no implicit namespace, so its `spec.server` block
# REQUIRES `namespace` — which namespace the server Deployment/Service/ConfigMap
# (and any minted credential Secret) land in. The other server fields are
# identical to the namespaced Repository form, flattened in.
#
# Because a cluster-scoped object can't own namespaced children via an
# ownerReference, the controller tracks and cleans up the server objects with a
# finalizer + labels. If the repo credentials Secret lives in a different
# namespace than the server, the operator mirrors it next to the server pod
# (envFrom can't cross namespaces).
#
# Requires installScope=cluster. Field shapes verified against crates/api:
# externally-tagged `server.auth.generate`; `server.namespace` required here.
---
apiVersion: kopiur.home-operations.com/v1alpha1
kind: ClusterRepository
metadata:
name: platform
spec:
# backend / encryption / allowedNamespaces …
backend:
s3:
bucket: org-kopia-repo
endpoint: s3.us-east-1.amazonaws.com
region: us-east-1
auth:
secretRef:
name: platform-creds
namespace: kopiur-system # explicit ns required for cluster scope
encryption:
passwordSecretRef:
name: platform-creds
namespace: kopiur-system
key: KOPIA_PASSWORD
allowedNamespaces:
all: true
server:
namespace: kopiur-system # required: where the server objects land
auth: { generate: {} }
service: { type: ClusterIP, port: 51515 }

A cluster-scoped object can’t own namespaced children through an ownerReference. So the controller tracks and cleans up the server objects with a finalizer plus labels instead.

envFrom can’t cross namespaces, so any credential Secret living in a different namespace than the server is copied next to the server pod. That means the password Secret and the backend auth Secret, when they are separate.

Each reference’s source namespace is its own explicit namespace, falling back to the operator’s namespace. That is the same rule the repository uses for bootstrap, so “absent” always means one thing.

Changing server.namespace moves the server. The operator deletes the objects in the old namespace, copies included, and recreates them in the new one. It tracks the last-applied namespace in status.server.namespace.

For an object-store backend, such as S3, Azure, GCS, or B2, the server connects over the network, so there is no volume constraint.

For a filesystem backend the server pod must mount the repository volume, and that pod is long-lived:

Like a mover, the server pod mounts the filesystem repository read-write and writes to the backend. So it must be able to write the export.

The server gets the same hardened pod defaults as movers, including fsGroup: 65532. But fsGroup has no effect on NFS.

If the export is owned by a dedicated UID and GID, give the server the shared group through spec.server.podSecurityContext, which mirrors moverDefaults.podSecurityContext:

spec:
server:
podSecurityContext:
supplementalGroups: [3001] # the export's group; matches moverDefaults
moverDefaults:
podSecurityContext:
supplementalGroups: [3001]

Without it, the server crash-loops on startup, because it can’t read or write the repository. See Security context → NFS filesystem repositories.

The container-level spec.server.securityContext overrides the hardened container context, for instance runAsUser, independently of this.

The reconciler pins a status.server block (it never stores a password):

Terminal window
$ kubectl get repository nas-primary -n apps -o jsonpath='{.status.server}' | jq
Field Meaning
endpoint In-cluster address, <service>.<namespace>.svc:<port>.
namespace The namespace the server objects were last applied to. The operator uses it to detect a namespace change.
authMode The resolved auth mode: Generate, SecretRef, or Insecure.
readOnly The effective read-only state. It is true when either spec.mode: ReadOnly or spec.server.readOnly: true is set.
generatedSecretRef generate mode only. The operator-owned Secret holding the UI credentials.

When the server is disabled, status.server is cleared to null.

Delete the spec.server block from the Repository manifest and re-apply it. The operator then deletes the Deployment, the Service, the ConfigMap, and any generated Secret it owns:

Terminal window
$ kubectl apply -f your-repository.yaml # the manifest with the spec.server block removed

A complete, apply-ready Repository with spec.server. It uses an S3 backend and generate auth, and it carries commented HTTPRoute and NetworkPolicy templates:

deploy/examples/25-repository-server-ui.yaml
# Example 25 — Browse a repository with the Kopia web UI (`spec.server`)
#
# Setting `spec.server` on a Repository (or ClusterRepository) makes the operator
# run `kopia server start` in a Deployment and expose it via a Service, so you can
# browse snapshots/policies through Kopia's built-in HTML UI. Only a Service is
# created — wiring an Ingress/HTTPRoute is left to you (commented below).
#
# ┌─ SECURITY ─────────────────────────────────────────────────────────────────┐
# │ By default the UI is full read-write-DELETE. Anyone who can reach it can │
# │ READ, CREATE, and DELETE backups, and the server pod holds the repository │
# │ DECRYPTION KEY. Set `server.readOnly: true` (or `spec.mode: ReadOnly` on the │
# │ Repository) for a no-mutation UI — see example 26. Read-only still exposes │
# │ READING all backups, so keep auth + ClusterIP regardless. Defaults here are │
# │ the safe ones: │
# │ * `auth: { generate: {} }` — the operator mints random UI credentials into │
# │ an owned Secret (`<repo>-kopia-ui-auth`) and pins the ref to │
# │ `status.server.generatedSecretRef`. Read them with: │
# │ kubectl -n apps get secret nas-primary-kopia-ui-auth \ │
# │ -o jsonpath='{.data.password}' | base64 -d │
# │ * `service.type: ClusterIP` — not exposed outside the cluster by default. │
# │ The no-auth `insecure` mode exists but requires an explicit │
# │ `acknowledgeInsecure: true` (webhook-rejected otherwise). Strongly consider │
# │ a NetworkPolicy restricting who may reach the Service. │
# └─────────────────────────────────────────────────────────────────────────────┘
#
# Filesystem backends: a long-lived server holds the repo PVC, so it would block
# backup/restore movers on a ReadWriteOnce volume — the operator requires the repo
# PVC to be ReadWriteMany when `spec.server` is set on a filesystem Repository.
# Object-store backends (shown here) connect over the network and have no such
# constraint.
---
apiVersion: v1
kind: Secret
metadata:
name: nas-primary-creds
namespace: apps
type: Opaque
stringData:
AWS_ACCESS_KEY_ID: "REPLACE_ME"
AWS_SECRET_ACCESS_KEY: "REPLACE_ME"
KOPIA_PASSWORD: "choose-something-long-and-random"
---
apiVersion: kopiur.home-operations.com/v1alpha1
kind: Repository
metadata:
name: nas-primary
namespace: apps
spec:
backend:
s3:
bucket: my-backups
endpoint: s3.us-east-1.amazonaws.com
region: us-east-1
auth:
secretRef:
name: nas-primary-creds
encryption:
passwordSecretRef:
name: nas-primary-creds
key: KOPIA_PASSWORD
# The web UI server. Presence of this block enables it.
server:
# Default (and recommended): operator-generated credentials.
auth:
generate:
username: kopia # optional; defaults to "kopia"
# Alternatives:
# auth: { secretRef: { name: my-ui-creds, usernameKey: username, passwordKey: password } }
# auth: { insecure: { acknowledgeInsecure: true } } # NO auth — footgun
# readOnly: true # no-mutation UI (browse + restore only) — see example 26
service:
type: ClusterIP # ClusterIP (default) | NodePort | LoadBalancer
port: 51515
annotations: {} # the seam for your ingress/LB controller
# resources: { requests: { cpu: 25m, memory: 64Mi } }
# securityContext: {}
---
# Bring-your-own routing (uncommented as an illustration). Kopiur never creates an
# Ingress/HTTPRoute — point your own at the Service `nas-primary-kopia-ui:51515`.
#
# apiVersion: gateway.networking.k8s.io/v1
# kind: HTTPRoute
# metadata:
# name: nas-primary-kopia-ui
# namespace: apps
# spec:
# parentRefs:
# - name: internal
# namespace: gateway
# hostnames: ["kopia.example.internal"]
# rules:
# - backendRefs:
# - name: nas-primary-kopia-ui
# port: 51515
#
# And a NetworkPolicy is strongly recommended to restrict who can reach the Service:
#
# apiVersion: networking.k8s.io/v1
# kind: NetworkPolicy
# metadata:
# name: nas-primary-kopia-ui
# namespace: apps
# spec:
# podSelector:
# matchLabels:
# app.kubernetes.io/name: kopiur-server
# app.kubernetes.io/instance: nas-primary
# ingress:
# - from:
# - namespaceSelector:
# matchLabels: { kubernetes.io/metadata.name: gateway }