# Deploying on Kubernetes # This guide runs Foundry Virtual Tabletop on a Kubernetes cluster using a small set of plain manifests. They are intentionally generic — no specific gateway controller, storage provider, or secrets tooling is assumed — so you can adapt them to your environment. The deployment is built up in stages, and every stage follows the same rhythm: a diagram of the target, the files involved, the steps to apply them, and the reasoning behind the choices. Each stage is a complete, working system — stop whenever you have what you need: 1. [Deploy a single instance](#deploy-a-single-instance) — the [`manifests/`](manifests) base. 1. [Add a staging instance](#add-a-staging-instance) — a thin overlay over the same base. 1. [Share the download cache](#share-the-download-cache) — every instance downloads a release once, together. 1. [Publish through a Cloudflare Tunnel](#publish-through-a-cloudflare-tunnel) — the whole system on the internet, with no open ports. ## Prerequisites ## - A Kubernetes cluster and `kubectl` configured to reach it. - A default `StorageClass` (or edit [`pvc.yaml`](manifests/pvc.yaml)). - A [Gateway API] implementation with a `Gateway` you can attach routes to, plus DNS, if you want external access. (The [tunnel stage](#publish-through-a-cloudflare-tunnel) provides one.) - A [foundryvtt.com](https://foundryvtt.com/auth/register/) account with a purchased software license. Stages that need anything more say so at the top. ## Deploy a single instance ## One namespace holding one Foundry server: persistent data, credentials from a secret, and HTTPS routed in through your cluster's gateway. ```mermaid graph LR Users((Users)) -->|HTTPS / 443| Gateway subgraph Cluster Gateway("Gateway + HTTPRoute") -->|HTTP / 80| Service("Service") Service --> Pod("Foundry pod") Pod --> PVC[("PersistentVolumeClaim
/data")] Secret("Secret
config.json") -.-> Pod end ``` ### Files ### Everything lives in [`manifests/`](manifests), a ready-to-use [Kustomize] base pinned to the major image tag `:14`: | File | Resource | Purpose | | ---- | -------- | ------- | | [`namespace.yaml`](manifests/namespace.yaml) | `Namespace` | Isolates the instance. | | [`pvc.yaml`](manifests/pvc.yaml) | `PersistentVolumeClaim` | Persistent `/data` storage. | | [`secret.yaml`](manifests/secret.yaml) | `Secret` | Credentials as a `config.json`. | | [`deployment.yaml`](manifests/deployment.yaml) | `Deployment` | The Foundry pod. | | [`service.yaml`](manifests/service.yaml) | `Service` | Cluster-internal endpoint. | | [`httproute.yaml`](manifests/httproute.yaml) | `HTTPRoute` | External HTTPS access via the [Gateway API]. | ### Steps ### 1. Edit [`secret.yaml`](manifests/secret.yaml) with your credentials, and set `FOUNDRY_HOSTNAME` (in [`deployment.yaml`](manifests/deployment.yaml)) and the `hostnames` (in [`httproute.yaml`](manifests/httproute.yaml)) to your domain. Point the route's `parentRefs` at your cluster's `Gateway`. 1. Apply the manifests: ```console kubectl apply -k manifests/ ``` 1. Watch the pod start. On first launch it downloads and installs the Foundry distribution, so give it a minute: ```console kubectl --namespace foundryvtt rollout status deployment/foundryvtt ``` 1. Reach the server. Before wiring up DNS you can port-forward to it directly: ```console kubectl --namespace foundryvtt port-forward service/foundryvtt 30000:80 ``` Then open [http://localhost:30000](http://localhost:30000). Once your gateway and DNS are in place, browse to your configured hostname instead. ### How it works ### - **Stable hostname for licensing.** Foundry binds its license to the container hostname. The Deployment sets `hostname: foundryvtt` so the binding survives pod restarts. `FOUNDRY_HOSTNAME` is separate — it is the public address Foundry advertises in invitation links. - **One replica, `Recreate` strategy.** Foundry writes to a single `ReadWriteOnce` volume and is not designed to run as multiple concurrent replicas. `replicas: 1` plus `strategy: Recreate` guarantees the old pod releases the volume before the new pod claims it. (To run *several* games, see [adding a staging instance](#add-a-staging-instance) — each is its own single-replica deployment.) - **Hardened, read-only root filesystem.** The pod runs as non-root (UID/GID `1000`), drops all capabilities, and mounts its root filesystem read-only. Foundry still needs to write a couple of paths outside `/data`, so `/tmp` and `/home/node/resources` are backed by ephemeral `emptyDir` volumes. - **Probes instead of the Docker `HEALTHCHECK`.** Kubernetes ignores the image's built-in health check, so the Deployment declares its own probes against Foundry's `/api/status` endpoint. The startup probe carries a generous budget because the first boot downloads and installs the Foundry distribution before the server answers. - **Credentials as a mounted secret.** The `Secret` is projected to `/run/secrets/config.json`, the location the image reads for credentials. This keeps usernames, passwords, and keys out of the pod's environment. See the [secrets reference](../../README.md#secrets) for every supported key. - **TLS at the edge.** The Service speaks plain HTTP on port `80`. Terminate TLS at your `Gateway` listener and set `FOUNDRY_PROXY_SSL=true` (already in the Deployment) so invitation links and audio/video use `https`. Foundry relies on WebSockets, which the Gateway API's `HTTPRoute` carries without extra configuration. > [!TIP] > Prefer the classic `Ingress`? Swap > [`httproute.yaml`](manifests/httproute.yaml) for an `Ingress` routing your > host to the `foundryvtt` Service on port `80`. Nothing else changes. ## Add a staging instance ## A second, independent Foundry server — for example a stable "production" table and a "staging" instance for testing modules — built from the same base with a thin overlay. This is exactly how the maintainer runs production and staging side by side. ```mermaid graph LR subgraph prod ["namespace: foundryvtt"] SvcP("Service") --> PodP("Foundry pod") --> PVCP[("PVC /data")] end subgraph staging ["namespace: foundryvtt-staging"] SvcS("Service") --> PodS("Foundry pod") --> PVCS[("PVC /data")] end ``` ### Files ### The base is untouched; each additional instance is one small overlay beside it: | File | Resource | Purpose | | ---- | -------- | ------- | | [`overlays/staging/kustomization.yaml`](overlays/staging/kustomization.yaml) | `Kustomization` | Renames the namespace and hostnames over the base; optionally pins a different image tag. | ```yaml --- apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization namespace: foundryvtt-staging resources: - ../../manifests patches: # Every instance must present distinct hostnames: the container hostname # (license binding), FOUNDRY_HOSTNAME (invitation links), and the HTTPRoute # hostname (routing). - patch: | - op: replace path: /spec/template/spec/hostname value: foundryvtt-staging - op: replace path: /spec/template/spec/containers/0/env/1/value value: vtt-staging.example.com target: kind: Deployment name: foundryvtt - patch: | - op: replace path: /spec/hostnames/0 value: vtt-staging.example.com target: kind: HTTPRoute name: foundryvtt ``` ### Steps ### 1. Copy [`overlays/staging/`](overlays/staging) and replace the hostnames with your own. 1. Apply the overlay: ```console kubectl apply -k overlays/staging/ ``` 1. Watch it start, exactly as in the first stage: ```console kubectl --namespace foundryvtt-staging rollout status deployment/foundryvtt ``` ### How it works ### - **One base, thin overlays.** Every instance shares the hardened manifests; an overlay patches only what differs. A fix to the base reaches every instance on its next apply. - **Everything that must be unique, is.** The namespace keeps resources from colliding, each instance gets its own `PersistentVolumeClaim` (game data is never shared) and its own `Secret`, and the three hostnames — container `hostname` (license binding), `FOUNDRY_HOSTNAME` (invitation links), and the `HTTPRoute` hostname (routing) — are distinct and stable per instance. > [!NOTE] > Different instances can run different Foundry versions. Pin each overlay's > image tag — for example `:14.369` for an exact minor release — > and set the `FOUNDRY_VERSION` environment variable to match the version you > want installed. ## Share the download cache ## One cache volume serving every instance, so each Foundry release is downloaded exactly once no matter how many instances want it. This stage needs a `StorageClass` capable of `ReadWriteMany` (NFS, CephFS, EFS, Azure Files, and similar). ```mermaid graph LR subgraph prod ["namespace: foundryvtt"] PodP("Foundry pod") --> PVCP[("PVC /data")] end subgraph staging ["namespace: foundryvtt-staging"] PodS("Foundry pod") --> PVCS[("PVC /data")] end PodP & PodS -.-> Cache[("shared RWX volume
container cache")] ``` ### Files ### Each instance adds a claim and a small Deployment patch to its overlay: | File | Resource | Purpose | | ---- | -------- | ------- | | `cache-pvc.yaml` (one per instance) | `PersistentVolumeClaim` | `ReadWriteMany` claim onto one shared backing directory. | | additions to `overlays/*/kustomization.yaml` | `Kustomization` | Includes the claim and mounts the cache. | ### Steps ### 1. Create a `cache-pvc.yaml` in each instance's overlay: ```yaml --- apiVersion: v1 kind: PersistentVolumeClaim metadata: name: foundryvtt-shared-cache spec: accessModes: [ReadWriteMany] storageClassName: resources: requests: storage: 5Gi ``` 1. In each overlay's `kustomization.yaml`, add the claim to `resources:` and patch the Deployment to mount it and point `CONTAINER_CACHE` at it: ```yaml - patch: | - op: add path: /spec/template/spec/volumes/- value: name: shared-cache persistentVolumeClaim: claimName: foundryvtt-shared-cache - op: add path: /spec/template/spec/containers/0/volumeMounts/- value: name: shared-cache mountPath: /cache - op: replace path: /spec/template/spec/containers/0/env/0/value value: /cache target: kind: Deployment name: foundryvtt ``` 1. Re-apply each overlay. ### How it works ### - **Start-ups coordinate through the cache itself.** One instance downloads a release while the others wait for the finished file; a crashed downloader's claim is taken over; different versions never collide. - **Namespaces don't share claims.** `PersistentVolumeClaim`s are namespaced, so instances in different namespaces need their claims provisioned onto the same backing directory — for example an NFS CSI storage class with a fixed `subDir`, or static `PersistentVolume`s pointing at one export. > [!NOTE] > `CONTAINER_CACHE_SIZE` eviction is least-recently-used, so a cache shared > across major versions keeps whatever its instances actually install — an > older major's release survives as long as some instance still uses it. ## Publish through a Cloudflare Tunnel ## The whole system on the internet with no inbound ports: a [Cloudflare Tunnel] acts as the Gateway implementation, so the `HTTPRoute`s are unchanged except for their `parentRefs`. TLS terminates at Cloudflare's edge and `cloudflared` maintains outbound-only connections. This stage needs a [Cloudflare](https://www.cloudflare.com) account with a domain you manage. This is how the maintainer publishes their own instances. ```mermaid graph LR Users((Users)) -->|HTTPS| Edge("Cloudflare edge") Edge <-->|outbound-only tunnel| CFD("cloudflared pods") subgraph Cluster CFD -->|vtt.example.com| SvcP("production Service") CFD -->|vtt-staging.example.com| SvcS("staging Service") end ``` ### Files ### The gateway definition lives in [`cloudflare/`](cloudflare); the API token is created imperatively so it never lands in a file: | File | Resource | Purpose | | ---- | -------- | ------- | | [`cloudflare/gatewayclass.yaml`](cloudflare/gatewayclass.yaml) | `GatewayClass` | The tunnel as a [Gateway API] implementation. | | [`cloudflare/gateway.yaml`](cloudflare/gateway.yaml) | `Gateway` | The edge gateway that routes attach to. | ```yaml --- apiVersion: gateway.networking.k8s.io/v1 kind: GatewayClass metadata: name: cloudflare-gateway spec: controllerName: github.com/pl4nty/cloudflare-kubernetes-gateway # The Secret holding the Cloudflare API token; created imperatively so the # token never lands in a file. See the guide. parametersRef: group: "" kind: Secret name: cloudflare-api-token namespace: cloudflare-gateway --- apiVersion: gateway.networking.k8s.io/v1 kind: Gateway metadata: name: cloudflare-edge namespace: cloudflare-gateway spec: gatewayClassName: cloudflare-gateway listeners: - name: http port: 80 protocol: HTTP allowedRoutes: namespaces: from: All ``` ### Steps ### 1. Install the [cloudflare-kubernetes-gateway] controller (pin a release): ```console kubectl apply -k https://github.com/pl4nty/cloudflare-kubernetes-gateway/config/default?ref=v0.10.1 ``` 1. Create a namespace and a Secret holding a [Cloudflare API token] with the `Account:Cloudflare Tunnel:Edit` and `Zone:DNS:Edit` permissions: ```console kubectl create namespace cloudflare-gateway kubectl --namespace cloudflare-gateway create secret generic cloudflare-api-token \ --from-literal=ACCOUNT_ID='' \ --from-literal=TOKEN='' ``` 1. Apply the gateway definition: ```console kubectl apply -f cloudflare/ ``` 1. Point each instance's route at the tunnel by editing the `parentRefs` in [`httproute.yaml`](manifests/httproute.yaml): ```yaml parentRefs: - name: cloudflare-edge namespace: cloudflare-gateway ``` Re-apply each overlay. ### How it works ### - **The controller does the lifting.** It creates and manages the tunnel and its `cloudflared` Deployment, and publishes a DNS record for every `HTTPRoute` hostname — there is no port forwarding or NAT to configure. - **The application never noticed.** Routing stayed an `HTTPRoute`; only the `parentRefs` changed. `FOUNDRY_PROXY_SSL=true` keeps invitation links and audio/video on `https`, now terminated at Cloudflare's edge. > [!TIP] > The tunnel and an in-cluster `Gateway` compose nicely: keep a second > `HTTPRoute` attached to an internal Gateway with a LAN-only hostname, and > the same Service is reachable both from the internet (through the tunnel) > and directly on your network — split-horizon with no extra application > configuration. ## Updating ## The in-application "Update Software" tab is disabled in this image. The Deployment pins the `:14` tag with `imagePullPolicy: Always`, so a rollout re-pulls the newest matching image: ```console kubectl --namespace foundryvtt rollout restart deployment/foundryvtt ``` To move to a new major version, change the image tag in [`deployment.yaml`](manifests/deployment.yaml) (and `FOUNDRY_VERSION` if you pin it) and re-apply. [Cloudflare API token]: https://developers.cloudflare.com/fundamentals/api/get-started/create-token/ [Cloudflare Tunnel]: https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/ [Gateway API]: https://gateway-api.sigs.k8s.io/ [Kustomize]: https://kustomize.io/ [cloudflare-kubernetes-gateway]: https://github.com/pl4nty/cloudflare-kubernetes-gateway