Deploying on Kubernetes¶
The chart in helm/skills-gateway/ installs the gateway as a single Deployment,
a Service, and — optionally — an Ingress, a ServiceAccount and a ConfigMap of
application configuration. This guide covers what the chart needs from you
before it will install, what it deliberately refuses to guess, and a worked
example on serverless Kubernetes.
Prerequisites¶
| What | Why | Notes |
|---|---|---|
| A PostgreSQL database | Snapshots, the audit ledger, tokens and grants live there | The chart does not bring one. Create the database and a Secret with key password. |
| An OIDC client | The whole web surface authenticates with OIDC | Client id, client secret, and the three endpoint URIs. See Identity providers. |
| Access to the image | ghcr.io/skillsgateway/skillsgateway, or your own mirror |
For a private mirror, create a kubernetes.io/dockerconfigjson Secret and name it in imagePullSecrets. |
| A storage decision | The chart will not install without one | See Storage — this is the one value that has no default. |
Installing¶
A minimal my-values.yaml:
image:
repository: ghcr.io/skillsgateway/skillsgateway
# A released version, or an image digest — see the container image reference.
tag: "<released-version>"
postgresql:
host: postgres.example.com
existingSecret: skills-gateway-db # key: password
oidc:
clientId: skills-gateway
existingSecret: skills-gateway-oidc # key: client-secret
authorizationUri: https://idp.example.com/oauth2/v2.0/authorize
tokenUri: https://idp.example.com/oauth2/v2.0/token
jwkSetUri: https://idp.example.com/discovery/v2.0/keys
issuer: https://idp.example.com/v2.0
persistence:
mode: existingClaim
existingClaim: skills-gateway-data
Configuring the application¶
The chart's own keys cover the database, the identity provider and storage. Everything else the gateway reads — see Configuration — is set through two passthroughs, so no chart change is needed to reach a setting.
Role enforcement is off by default
skills-gateway.roles.enabled defaults to false, and while it is false
every authorization check passes for every authenticated principal. A
gateway installed and left alone therefore grants full administrative
access to anyone who can complete an OIDC login. Turn it on, and name at
least one config admin, before the first person logs in.
Structured configuration: config¶
Anything under config is rendered verbatim into a ConfigMap, mounted at
/etc/skills-gateway/application.yaml, and layered over the application.yaml
built into the image through SPRING_CONFIG_ADDITIONAL_LOCATION. The image
keeps the defaults; this file overrides them.
config:
skills-gateway:
roles:
enabled: true
admins:
- platform-admin@example.com
estate:
marketplaces:
- name: internal-skills
url: https://git.example.com/skills/internal.git
grants:
- principal: skills-reviewers
role: approver
marketplace: internal-skills
This is the place for the declarative estate, which is nested lists and expresses poorly as environment variables. It matters more than it looks: the admin API requires an interactive OIDC session, so estate YAML is currently the only way to configure a gateway from automation at all (#128 tracks changing that).
The estate is reconciled at startup, before the web surface serves its first
request, so the declaration is in force from the first login. The chart hashes
config into a pod annotation, which means editing it and upgrading rolls the
pods — without a restart a changed ConfigMap would be refreshed on disk and
never read.
A ConfigMap is not a Secret
Never put a webhook secret, an audit-sink credential or anything else
sensitive in config. Use extraEnv with valueFrom.secretKeyRef, or
extraEnvFrom with a secretRef, and reference the environment variable
from the estate declaration as described in
Declarative estate configuration.
Single settings and secrets: extraEnv, extraEnvFrom¶
extraEnv:
- name: SKILLSGATEWAY_RETENTION_ENABLED
value: "true"
- name: SGW_ESTATE_CI_BOT_SECRET
valueFrom:
secretKeyRef:
name: skills-gateway-estate
key: ci-bot-secret
extraEnvFrom:
- secretRef:
name: skills-gateway-extra
Environment names are Spring's relaxed-binding form of the property:
skills-gateway.roles.enabled is SKILLSGATEWAY_ROLES_ENABLED.
Storage¶
The gateway keeps three sets of repositories on disk under /data: the
quarantine, the published content the facade serves, and hosted first-party
marketplaces. PostgreSQL records which snapshots exist and which are approved.
Those two halves are one estate, and only one of them survives a restart onto an
empty volume. A gateway that comes back with an empty /data still reports its
snapshots as published, still lists them in the catalog, and can serve none of
them — with no rehydration path, because a published snapshot is a pinned
history, not something re-fetchable from upstream.
So the chart has no storage default and refuses to render without a choice:
Any other value — including leaving mode empty — stops helm install with a
message explaining what is lost. Choosing ephemeral is fine for a demo or a
throwaway environment; it is a deliberate act either way.
The claim must already exist in the release namespace. The chart does not create a PersistentVolumeClaim, because the interesting decisions — storage class, access mode, size, static or dynamic provisioning — belong to the cluster, not to this chart. Which of them are even available to you is constrained by the platform — see Storage options on serverless Kubernetes.
replicaCount depends on the storage backend¶
On the default filesystem backend, storage is a plain filesystem written by
the ingestion pipeline, the approval step and the facade's own repacking.
There is no cross-pod locking, so two replicas writing the same volume can
interleave a fetch and a publish into a corrupt repository. The chart refuses to
render replicaCount > 1 there rather than trusting you to know that.
Keep replicaCount: 1. A rolling update briefly runs two pods, so prefer the
Recreate strategy — or accept a moment's overlap — on volumes that allow only
one writer anyway.
On the object-store backend concurrent writers are safe by construction, and
more than one replica becomes possible — but only with the gateway's
uncoordinated background sweeps and pollers switched off on the scaled-out
deployment, which the chart also checks. See
Running more than one replica.
Ingress and TLS¶
The Ingress is off by default:
ingress:
enabled: true
className: nginx
annotations:
cert-manager.io/cluster-issuer: letsencrypt
hosts:
- host: skills-gateway.example.com
paths:
- path: /
pathType: Prefix
tls:
- secretName: skills-gateway-tls
hosts:
- skills-gateway.example.com
TLS is not optional in production
The git facade authenticates clients with personal access tokens over HTTP Basic — see the facade reference. Every clone and every fetch puts a long-lived credential on the wire. Without TLS, anything on the path can read it and then act as that client indefinitely.
Terminate TLS at the Ingress or at a load balancer in front of it, and never expose the container's HTTP listener directly.
Both the portal and /git/** are served by the same container on the same port,
so one host and one / path is the normal configuration.
Identity and security context¶
The chart creates a ServiceAccount for the release and runs the pod under it, because workload identity binds to a named account:
serviceAccount:
create: true
annotations:
eks.amazonaws.com/role-arn: arn:aws:iam::000000000000:role/skills-gateway
By default the container runs as the image's non-root user (uid 65532), with all
capabilities dropped, privilege escalation disabled and a read-only root
filesystem; the chart mounts an emptyDir at /tmp so the runtime keeps
somewhere to write. podSecurityContext.fsGroup is what makes the mounted data
volume writable by that user.
Storage options on serverless Kubernetes¶
Serverless node pools — AWS Fargate and its equivalents — constrain storage sharply, and the constraints interact. This is the short version of the choice, for the case where the platform is already fixed:
| Option | Available? | Verdict for this workload |
|---|---|---|
| Block storage (RWO) | No | Not a trade-off — there is no configuration that gets you one. |
| Network filesystem (RWX, e.g. NFS/EFS) | Yes, static provisioning only | Works. Good for a proof of concept; the substrate git is worst on. |
| Parallel filesystem (e.g. FSx for Lustre) | No | Unavailable on serverless pods. |
| Object storage | Supported | The production answer here: storage.backend: object-store, credentials from workload identity, and no volume required at all — see the storage guide. |
| Block storage on managed nodes | Yes, if you can choose the platform | What today's storage was designed for. |
Block storage is unavailable, not merely awkward. On EKS, AWS states plainly that "You can't mount Amazon EBS volumes to Fargate Pods", and the reason is structural: the EBS CSI node component is a DaemonSet, and "Daemonsets aren't supported on Fargate" — the CSI controller can run there, the node component cannot. There is no ReadWriteOnce volume to be had, so the single-writer filesystem this gateway was built around has nowhere to live.
A network filesystem works, with static provisioning only. A Fargate pod mounts EFS without any driver installation, but "You can't use dynamic persistent volume provisioning with Fargate nodes, but you can use static provisioning" — so the PersistentVolume and its access point must exist before the pod does, which is what the worked example below creates by hand.
Two caveats worth understanding before choosing it:
- Git is a bad fit for a network filesystem. Git's on-disk format is a
random walk over large packfiles: object lookup seeks into a pack, follows a
delta chain, and does it again — plus constant small metadata operations
(
staton loose objects, lock files,fsyncon refs). On a local disk the page cache absorbs all of that. Over NFS each one is a network round trip. Expect clone and repack times in multiples, not percentages. - RWX does not enforce the single-writer assumption. A network filesystem
is ReadWriteMany by nature, so nothing stops a second pod from mounting the
same volume and writing to it. Today's storage has no cross-pod locking, so
replicaCount: 1holds by convention here rather than by construction — an RWO block volume would have refused the second writer for you.
A parallel filesystem is not an option either: FSx for Lustre is listed as unavailable to Fargate pods.
Object storage is the answer on serverless platforms specifically. Not
because of scale — because the alternatives here are "impossible" and "the
substrate git is worst on". It is implemented: set
storage.backend: object-store, and the repositories live in a bucket with
local disk as a cache. Fargate has no instance metadata service, so credentials
come from workload identity — see
Choosing and migrating the storage backend.
If the platform choice is still open, an ordinary managed node group with an RWO block volume is what the current storage implementation was designed for, and it enforces the single-writer property structurally rather than by convention. Serverless is worth its constraints for plenty of workloads; a git server on a network filesystem is not the case it is best at.
Two operational notes that are easy to miss¶
- No instance metadata service. Serverless pods typically cannot reach IMDS, so cloud credentials come from workload identity bound to a named service account (IRSA on EKS). That is why the chart creates a ServiceAccount and lets you annotate it; on EKS the documented remedy for a Fargate pod that needs IAM credentials is exactly that.
- Egress needs a NAT path. Fargate pods run in private subnets only, with no direct route to an internet gateway. Ingestion fetches from upstream git over the network, so without a NAT gateway (or equivalent egress) registration and sync will hang rather than fail quickly. Inbound traffic likewise arrives through a load balancer you place in front of the pods.
Worked example: a statically provisioned network filesystem¶
Create the access point and the PersistentVolume by hand (ids below are placeholders):
apiVersion: v1
kind: PersistentVolume
metadata:
name: skills-gateway-data
spec:
capacity:
storage: 100Gi # ignored by the EFS driver; required by the API
volumeMode: Filesystem
accessModes:
- ReadWriteMany
persistentVolumeReclaimPolicy: Retain
storageClassName: "" # static binding: no class, no provisioner
csi:
driver: efs.csi.aws.com
volumeHandle: fs-0123456789abcdef0::fsap-0123456789abcdef0
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: skills-gateway-data
namespace: skills-gateway
spec:
accessModes:
- ReadWriteMany
storageClassName: ""
resources:
requests:
storage: 100Gi
volumeName: skills-gateway-data
Give the access point a POSIX owner of 65532:65532 so the non-root container
can write to it, then install with persistence.mode: existingClaim and
persistence.existingClaim: skills-gateway-data.
This is a proof-of-concept substrate
It works, and it is not what you want in production — see
the comparison above. It is a
reasonable substrate for a proof of concept, a pilot, or a small internal
estate. The production answer is the object-store backend, which stops
treating a POSIX filesystem as the source of truth; moving to it is an
offline, verified, reversible copy described in
Choosing and migrating the storage backend.
Verifying the install¶
kubectl -n skills-gateway rollout status deploy/skills-gateway
kubectl -n skills-gateway logs deploy/skills-gateway | grep estate
Then log in to the portal and confirm the estate reconciled — GET /api/estate
reports the last run, and the audit ledger carries its entries under the
config-reconciler principal. See
Declarative estate configuration.