Skip to content

OpenCloud

OpenCloud is the household's file sync and share: one Go binary, no database, with desktop, iOS and Android clients. Upstream publishes no open Helm chart, so opencloud/ is plain manifests around the official image: deployment.yaml, service.yaml, pvc.yaml, httproute.yaml, servicemonitor.yaml, secrets.yaml, networkpolicy.yaml, namespace.yaml and two ConfigMaps, csp.yaml and authentik-blueprint.yaml, per Conventions → Official upstream sources only.

At a glance

URL cloud.infra.k8s.wlkr.ch, LAN and VPN only
Authentication Authentik OIDC for every client; the built-in identity provider does not run
Storage rook-ceph-block PVC for /var/lib/opencloud, the PosixFS driver
Database None; metadata lives in extended attributes and the embedded NATS store
Secrets kv/opencloud/config in OpenBao, generated once
Files opencloud/

Storage

PosixFS keeps the files on the volume in the tree the user sees, with the metadata in xattrs, which ext4 provides, and versions, trash and the search index beside them. One volume holds the whole instance, so the nightly Velero snapshot is a complete copy, and a restore is readable with ls. The driver's non-collaborative mode requires that nothing else writes to the tree, which a single Deployment on a ReadWriteOnce volume guarantees.

The alternative, blobs in an S3 bucket with metadata on a volume, splits one instance across two backup mechanisms, one of which does not exist for buckets here, and upstream documents no migration into or out of it.

Upstream's backup guidance is to stop the instance first. The RBD snapshot is crash-consistent instead: metadata is written per operation on the same filesystem, and the nightly Velero run sees no sync traffic. The search index is rebuilt if it disagrees after a restore; nothing is lost.

Configuration

The values in opencloud/deployment.yaml that are not self-explanatory:

Setting Why
opencloudeu/opencloud-rolling, not the production opencloudeu/opencloud Rolling ships every three weeks on a current Alpine; the production line gets a release about every six months, and its image carried a fixable critical in an Alpine package that the image-scan gate refuses. Move to the production line at a release that passes the gate. Either way a major bump waits for a human
args: ["server"], no init opencloud init only writes random secrets and IDs into opencloud.yaml. Every one of them arrives as an environment variable from opencloud-env instead, so /etc/opencloud is an emptyDir and the pod carries nothing a backup would have to protect
strategy: Recreate, one replica The volume is ReadWriteOnce, and PosixFS in non-collaborative mode tolerates one writer
runAsUser: 1000, fsGroup: 1000, read-only root, the namespace enforces restricted The image runs as that user and writes only under /var/lib/opencloud, /etc/opencloud and /tmp, the last two emptyDirs
OC_EXCLUDE_RUN_SERVICES=idp Authentik is the identity provider; the built-in one would be a second login
OC_OIDC_ISSUER=https://auth.k8s.wlkr.ch/ The four Authentik providers run in global issuer mode, whose issuer is the bare hostname; see OIDC
PROXY_CSP_CONFIG_FILE_LOCATION, the policy in opencloud/csp.yaml The default Content-Security-Policy allows connections and frames to the server itself only, so the browser refuses the discovery, token and silent-renew requests to an external IdP and the page spins forever. The file adds auth.k8s.wlkr.ch to connect-src and frame-src; OpenCloud merges it into the default rather than replacing it, so the default's update.opencloud.eu and GitHub app-store hosts stay in the browser policy. They matter only to a browser, the update check is off, and the server itself reaches neither
PROXY_OIDC_REWRITE_WELLKNOWN The desktop and mobile clients discover the identity provider through the server's own /.well-known/openid-configuration
PROXY_OIDC_ACCESS_TOKEN_VERIFY_METHOD=jwt Authentik's access tokens are JWTs, so the server checks signature and issuer itself instead of calling userinfo on every request
PROXY_OIDC_AUDIENCES, the four client IDs Authentik writes the client ID into aud, so the server accepts only tokens minted for one of its own clients, not any token from the same issuer. Without the list the proxy logs at every start that audience validation is off
PROXY_AUTOPROVISION_ACCOUNTS, with sub as the username Accounts are created in the built-in IDM on first login and keyed on the sub claim, which never changes, per Conventions → Authentication. Upstream's own autoprovisioning example does the same. Users see display names, never this value
GRAPH_USERNAME_MATCH=none Authentik's sub is a SHA-256 hex string, and the default rule refuses a username that starts with a digit: Invalid username, and the first login ends in a 400
FRONTEND_READONLY_USER_ATTRIBUTES Name and mail come from Authentik on every login, so the profile page must not offer to edit them
PROXY_ROLE_ASSIGNMENT_DRIVER=oidc on the groups claim, GRAPH_ASSIGN_DEFAULT_USER_ROLE=false The role comes from Authentik group membership, and a user in neither group gets no role and no access; see Usage
STORAGE_USERS_ID_CACHE_STORE=nats-js-kv PosixFS requires the NATS key-value store for its id cache; the embedded NATS provides it
FRONTEND_CHECK_FOR_UPDATES=false The web frontend's version check is the one outbound call the server makes on its own. Nothing phones home, the same rule as every platform component
PROXY_DEBUG_ADDR=0.0.0.0:9205 Every service in the binary has a debug port; the proxy's carries the request metrics, the health endpoints and the one ServiceMonitor
HTTPRoute on infra-gateway, not apps-gateway Only apps-gateway gets a public DNS record. Phones sync on the LAN and the VPN; moving to the public gateway is one route change

OIDC

OpenCloud's web, desktop, iOS and Android clients are four OIDC clients with IDs fixed by the clients (web, OpenCloudDesktop, OpenCloudIOS, OpenCloudAndroid), all public, authorization code with PKCE, so opencloud/authentik-blueprint.yaml holds four providers and four applications. Only the web one is visible in the Authentik library; the native ones carry blank://blank as their launch URL.

The server and the native clients verify every token against the one issuer the server advertises, and Authentik derives a provider's issuer from its application slug. All four providers therefore use issuer_mode: global, and the platform rewrites /.well-known/openid-configuration on auth.k8s.wlkr.ch to the opencloud application's document; see One issuer for OpenCloud.

The desktop client redirects to a loopback address with a random port, so its redirect URI is a regular expression; the mobile ones are custom-scheme URLs the apps register. There are no client secrets, so nothing lands in OpenBao for Authentik's side, and the platform repository only mounts the blueprint.

Secrets

secrets.yaml generates what opencloud init would: a Password generator for the JWT, transfer, URL-signing and service-account secrets, the machine-auth and system-user API keys and the IDM service passwords, which the users, groups, auth-basic and graph services also receive as their LDAP bind passwords, and one UUID generator per ID, because a UUID generator yields one value and a PushSecret reads one generator. Each PushSecret writes its keys into kv/opencloud/config once; the ExternalSecret templates them into opencloud-env under the environment variable names. Losing the OpenBao path and regenerating is a new instance: the JWT secret signs the internal tokens and the IDs name the storage mount and the system user, so the data on the volume would no longer match.

idm-admin-password is the built-in IDM's admin account, which the first start creates. With the IdP excluded nothing presents a login form for it, so there is no local break-glass account here, not even the one rule 6 allows: Authentik is the only way in.

Network policy

The rules in opencloud/networkpolicy.yaml, and why each is there. The shape is Conventions → Ship a CiliumNetworkPolicy.

Rule Why
Ingress from the namespace, from host and remote-node; egress to the namespace The rules every policy carries: What every policy contains
Ingress from ingress on 9200 The Gateway reaches the server directly; OpenCloud handles its own login, and the desktop and mobile clients arrive the same way
Ingress from Prometheus on 9205, GET /metrics only The proxy's metrics; the health endpoints on the same port are the kubelet's, covered by host and remote-node
Egress from the server to the Authentik server pods on 9000 Discovery, the signing keys and the token endpoint go to auth.k8s.wlkr.ch, which resolves to the Gateway's own address, private but world to Cilium. The Gateway's Envoy then checks this policy against the Authentik pod it picks and answers 403 itself when that pod is not listed, so both the name and the pod are allowed
Egress from the server to auth.k8s.wlkr.ch on 443 The other half of the same path. Hubble records one dropped SYN to this name at each pod start: the first packet after a fresh DNS answer races the FQDN policy update and the retransmit passes, the known artefact, not a missing rule

No app store, no update check, no mail, no object store: the search index and NATS run inside the process.

Usage

Membership in one of two Authentik groups is an account:

Group OpenCloud role
opencloudAdmin Administrator
opencloudUser User

A user in neither group can log in to Authentik but is refused by OpenCloud with no role assigned. The groups also appear as groups inside OpenCloud, because autoprovisioning mirrors the groups claim.

Clients: the desktop client for macOS 13 and later, Windows and Linux, the iOS app from the App Store and the Android app from Google Play or F-Droid. Each takes the server URL and opens Authentik in the browser.

Health check

kubectl -n opencloud get pods,pvc,externalsecret,pushsecret
curl -s https://cloud.infra.k8s.wlkr.ch/.well-known/openid-configuration | jq -r .issuer

The pod is Running, every PushSecret and the ExternalSecret are Ready, and the issuer printed is https://auth.k8s.wlkr.ch/. After a first start, the idm.boltdb on the volume is larger than the 32 KiB of an empty Bolt file; see Pitfalls.

kubectl -n opencloud exec deploy/opencloud -- ls -l /var/lib/opencloud/idm/

Pitfalls

Symptom Cause
ExternalSecret stays SecretSyncedError on the first sync The PushSecrets and the ExternalSecret are in different waves but OpenBao is written asynchronously; the retry covers it within minutes
The web page shows the loading animation forever, the console reports a Content-Security-Policy violation for auth.k8s.wlkr.ch The served policy is the default, not csp.yaml: the ConfigMap or PROXY_CSP_CONFIG_FILE_LOCATION is missing, or the mount path differs from the variable
Login succeeds in Authentik, OpenCloud answers 403 The user is in neither group, or the issuer in the token differs from OC_OIDC_ISSUER: a provider without issuer_mode: global
The desktop client's login tab never returns The loopback redirect did not match the regular expression; the port is random and the path may be empty
Prometheus shows the target down The policy allows GET /metrics only; any other path on 9205 from Prometheus is refused
Login ends on "Not logged in"; the log shows failed to add user with LDAP Result Code 49 from graph and not found from idm The IDM database under idm/ on the volume is a bare Bolt file: a first pod that died during the bootstrap, as in the row below, left it created but without the service users, and every later start sees the file and skips the bootstrap. Remove the idm directory (Recovery); the next start bootstraps it. Only safe while no account exists yet, which is the case when this symptom appears
The pod exits with error parsing mapping JSON: unexpected end of JSON input from the search service The Bleve index under search/ on the volume was created by a pod that died before writing the mapping, which happens when another service fails on the same first start. Remove the search directory (Recovery); the index is rebuilt from the files on the next start

Recovery

Removing a directory from the volume

For the two first-start failures above. The volume is ReadWriteOnce and stays attached to the node of the crashing pod, so the debug pod goes on that node; scaling the Deployment to zero first does not hold, self-heal restores the replica within a minute.

  1. Start a debug pod beside the crashing one, on its node and from its image, with the restricted context the namespace enforces:

    NODE=$(kubectl -n opencloud get pod -l app.kubernetes.io/name=opencloud -o jsonpath='{.items[0].spec.nodeName}')
    IMAGE=$(kubectl -n opencloud get deploy opencloud -o jsonpath='{.spec.template.spec.containers[0].image}')
    kubectl -n opencloud apply -f - <<EOF
    apiVersion: v1
    kind: Pod
    metadata:
      name: opencloud-debug
    spec:
      nodeName: $NODE
      restartPolicy: Never
      automountServiceAccountToken: false
      securityContext:
        runAsNonRoot: true
        runAsUser: 1000
        runAsGroup: 1000
        fsGroup: 1000
        seccompProfile:
          type: RuntimeDefault
      containers:
        - name: shell
          image: $IMAGE
          command: ["sleep", "3600"]
          securityContext:
            allowPrivilegeEscalation: false
            capabilities:
              drop: ["ALL"]
          volumeMounts:
            - name: data
              mountPath: /var/lib/opencloud
      volumes:
        - name: data
          persistentVolumeClaim:
            claimName: opencloud-data
    EOF
    
  2. Remove the directory, idm or search:

    kubectl -n opencloud exec opencloud-debug -- rm -r /var/lib/opencloud/search
    
  3. Delete the debug pod; the container's next restart recreates what was removed:

    kubectl -n opencloud delete pod opencloud-debug