Deploy StorageLink with Helm Chart on GKE
TLDR - Quick Summary
What: Deploy StorageLink on GKE using a Helm chart with bundled or external PostgreSQL
Steps: Create GKE cluster, download the chart, run
helm installQuick start:
helm install storagelink ./storagelink \
--namespace storagelink --create-namespace \
--set postgresql.auth.password=$(openssl rand -hex 16) \
--set admin.username=admin \
--set admin.password='YourSecurePassword1!'
Overview
The StorageLink Helm chart simplifies deploying StorageLink on Google Kubernetes Engine (GKE). It handles creating all Kubernetes resources (Deployments, Services, ConfigMaps, Secrets, PVCs, ServiceAccounts, NetworkPolicies, PodDisruptionBudgets) and supports two database modes:
- Bundled PostgreSQL (default) — runs a PostgreSQL container inside the cluster using the Bitnami subchart. Good for testing and simple deployments.
- External database — connects to a managed Cloud SQL for PostgreSQL instance. Recommended for production.
For detailed configuration options (TLS modes, client IP preservation, security context, LFS mode, image verification, and more), see the Container Deployment Reference.
Architecture
┌──────────────────────────────────────────────┐
│ Google Kubernetes Engine (GKE) │
│ Namespace: storagelink │
│ │
HTTPS (443) │ ┌────────────┐ ┌────────────────┐ │
──────────────────┼──►│ Admin UI │───►│ Backend │ │
│ │ (Nginx) │ │ (Spring Boot) │ │
│ │ │ │ Port 8080 API │ │
│ └────────────┘ └───────┬────────┘ │
│ │ │
│ ┌───────▼────────┐ │
│ │ PostgreSQL │ │
│ │ (bundled or │ │
│ │ external) │ │
│ └────────────────┘ │
└──────────────────────────────────────────────┘
Components:
- Backend — StorageLink API server (Spring Boot). Handles cloud storage operations and user management.
- Admin UI — Web-based administration dashboard (Nginx). Exposed via a LoadBalancer on port 443.
- PostgreSQL — Stores user accounts and configuration data.
Prerequisites
- A Google Cloud project with billing enabled
- gcloud CLI installed and configured
- Helm 3 installed
- kubectl installed
- Network access to Docker Hub, where the StorageLink images are published:
thorntech/storagelink-backendandthorntech/storagelink-ui
Download the Helm chart
Download the StorageLink Helm chart archive and extract it:
curl -LO https://help.thorntech.com/storagelink/downloads/storagelink-gcp-0.4.2.tgz
tar xzf storagelink-gcp-0.4.2.tgz
This creates a storagelink/ directory containing the chart (version 0.4.2, StorageLink 1.3.1), with GKE defaults already set. All helm install commands below reference this directory as ./storagelink.
Step 1: Create a GKE cluster
gcloud container clusters create storagelink-gke \
--zone us-central1-a \
--num-nodes 2 \
--machine-type e2-standard-2 \
--enable-network-policy
Get credentials for kubectl:
gcloud container clusters get-credentials storagelink-gke --zone us-central1-a
Verify the cluster is ready:
kubectl get nodes
Step 2: Create the namespace
kubectl create namespace storagelink
kubectl label namespace storagelink pod-security.kubernetes.io/enforce=restricted
The chart is compatible with the restricted Pod Security Standard, so enforcing it is recommended.
The images are public on Docker Hub (thorntech/storagelink-backend and thorntech/storagelink-ui), so no pull secret is needed. The tag defaults to the chart's appVersion (1.3.1). To pull from your own registry or mirror, set backend.image.repository, ui.image.repository and, if the registry needs credentials, imagePullSecrets.
Step 3: Install the Helm chart
Option A: Bundled PostgreSQL (quickstart)
This is the simplest option — a PostgreSQL container runs alongside the backend inside your cluster.
helm install storagelink ./storagelink \
--namespace storagelink \
--set postgresql.auth.password=$(openssl rand -hex 16) \
--set admin.username=admin \
--set admin.password='YourSecurePassword1!'
After a few minutes, all pods should be running:
kubectl get pods -n storagelink
Expected output:
NAME READY STATUS RESTARTS AGE
storagelink-backend-xxxxx 1/1 Running 0 2m
storagelink-backend-yyyyy 1/1 Running 0 2m
storagelink-postgresql-0 1/1 Running 0 2m
storagelink-ui-xxxxx 1/1 Running 0 2m
storagelink-ui-yyyyy 1/1 Running 0 2m
Option B: Cloud SQL for PostgreSQL (production)
For production deployments, use a managed Cloud SQL for PostgreSQL instance.
Create the database
# Create a Cloud SQL instance
gcloud sql instances create storagelink-pg \
--database-version=POSTGRES_16 \
--tier=db-f1-micro \
--edition=enterprise \
--region=us-central1 \
--root-password='<your-strong-password>' \
--authorized-networks=0.0.0.0/0 \
--storage-size=10GB
# Create the database
gcloud sql databases create swift_gw --instance=storagelink-pg
Get the instance IP:
gcloud sql instances describe storagelink-pg \
--format="value(ipAddresses[0].ipAddress)"
For production, use a private IP or the Cloud SQL Auth Proxy instead of --authorized-networks=0.0.0.0/0.
Install with external database
helm install storagelink ./storagelink \
--namespace storagelink \
--set postgresql.enabled=false \
--set externalDatabase.host=<cloud-sql-ip> \
--set externalDatabase.username=postgres \
--set externalDatabase.password='<your-strong-password>' \
--set externalDatabase.jdbcParams='?sslmode=require' \
--set admin.username=admin \
--set admin.password='YourSecurePassword1!'
Cloud SQL uses the postgres user by default, unlike the bundled PostgreSQL which uses swiftgw.
Step 4: Access the Admin UI
Get the external IP of the UI service:
kubectl get svc storagelink-ui -n storagelink
It may take 1–2 minutes for GCP to assign the external IP. Once available:
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
storagelink-ui LoadBalancer 34.118.236.101 34.172.240.196 443:30971/TCP 2m
Open https://<EXTERNAL-IP> in your browser. You will see a certificate warning because the chart generates a self-signed TLS certificate by default — this is expected for testing.
If you provided admin.username and admin.password, log in with those credentials. Otherwise, you will be prompted to create your admin account on first access.
If no admin credentials were provided and the UI is publicly accessible, the first person to reach the UI can create the admin account. See Security considerations below.
Licensing
Without a license, StorageLink starts unlicensed: sign in as the administrator and start the free 30-day trial or activate a purchased license under Settings → License. Until then, non-administrator users cannot sign in and file transfers are blocked. See Purchasing a License.
To license an unattended install instead, pass the license file you received (the whole JSON, on one line) with --set-file:
jq -c . license.json > license.min.json # only if the file spans several lines
helm install storagelink ./storagelink \
--namespace storagelink \
--set-file license.content=license.min.json \
# ... other values
The backend activates the license at startup and binds it to this deployment's database. This needs outbound HTTPS to licensing.thorntech.com, and a new or wiped database needs a new license.
Security considerations
Admin account
The admin.username and admin.password values trigger a post-install Helm Job that waits for the backend to become healthy, then calls the setup API to create the admin account. If an admin account already exists (e.g., on helm upgrade), the Job detects this and exits successfully without changes.
Restrict Admin UI access
Lock down the Admin UI to specific IP addresses using loadBalancerSourceRanges:
helm install storagelink ./storagelink \
--namespace storagelink \
--set 'ui.service.loadBalancerSourceRanges[0]=203.0.113.50/32' \
--set 'ui.service.loadBalancerSourceRanges[1]=198.51.100.0/24' \
# ... other values
Built-in hardening
The Helm chart includes these security defaults:
- Non-root containers — the backend runs as the image's own user (UID 1000, group 0; the chart does not pin
runAsUser), the UI as UID 1000 - Read-only root filesystem — enabled on the backend container
- Dropped capabilities — all Linux capabilities are dropped
- NetworkPolicies — restrict pod-to-pod and egress traffic
- ServiceAccounts — dedicated accounts with token automount disabled
- PodDisruptionBudgets — ensure availability during node maintenance
- Backend not externally exposed — backend Service is
ClusterIP, preventingX-Forwarded-Forspoofing that would defeat login lockout and forge audit source IPs - Client IP preservation — UI Service uses
externalTrafficPolicy: Localto preserve the client's real source IP for audit logging and login lockout - Zero-downtime rolling updates — UI Deployment uses
maxSurge: 1, maxUnavailable: 0to avoid dropped connections under theLocaltraffic policy
Custom TLS certificate
To use your own TLS certificate instead of the auto-generated self-signed one:
helm install storagelink ./storagelink \
--namespace storagelink \
--set-file ui.tls.certificate=path/to/tls.crt \
--set-file ui.tls.privateKey=path/to/tls.key \
# ... other values
Behind an ingress controller or L7 load balancer
To put an ingress controller or L7 load balancer in front of the Admin UI, give StorageLink its own hostname, route all of / to the UI, and point health checks at /index.html. Serving it under a path prefix (for example example.com/storagelink) is not supported. If the ingress terminates TLS, install with:
helm install storagelink ./storagelink \
--namespace storagelink \
--set ui.externalTlsTermination=true \
--set ui.service.type=ClusterIP \
--set ui.service.port=80 \
# ... other values
See Behind an Ingress Controller or L7 Load Balancer for the requirements and an example Ingress.
Configuration reference
Key values
| Parameter | Description | Default |
|---|---|---|
backend.image.repository | Backend image. Public on Docker Hub; override to use your own registry or mirror | thorntech/storagelink-backend |
backend.image.tag | Backend image tag. Empty uses the chart's appVersion (1.3.1); that tag must exist in the repository | "" |
ui.image.repository | UI image. Public on Docker Hub; override to use your own registry or mirror | thorntech/storagelink-ui |
ui.image.tag | UI image tag. Empty uses the chart's appVersion (1.3.1) | "" |
imagePullSecrets | Pull secrets, only needed for a private registry or mirror | [] |
admin.username | Initial admin username | "" |
admin.password | Initial admin password | "" |
security.jwtSecret | JWT signing key, at least 32 characters (openssl rand -base64 32); a shorter one fails the install. If empty, the chart generates one and keeps it across upgrades | "" |
license.content | The whole license JSON, for unattended installs (pass it with --set-file). If empty, license the deployment from the admin UI — see Licensing | "" |
license.key | Deprecated name for license.content (chart 0.3.0 and earlier). The value trial is ignored | "" |
config.cloudProvider | Cloud provider hint: azure, aws, or gcp | gcp |
config.maxHttpRequestHeaderSize | Largest request header the backend accepts. The image defaults to 8KB, which Microsoft Entra ID tokens exceed, so OIDC sign-in failed or worked only in a private window | 128KB |
backend.replicaCount | Number of backend replicas | 2 |
ui.replicaCount | Number of UI replicas | 2 |
ui.tls.certificate | Custom TLS certificate (PEM) | "" (self-signed) |
ui.tls.privateKey | Custom TLS private key (PEM) | "" |
ui.service.type | LoadBalancer, or ClusterIP behind an ingress controller | LoadBalancer |
ui.service.port | UI Service port. Behind an ingress that terminates TLS, use 80 | 443 |
ui.service.loadBalancerSourceRanges | Restrict Admin UI to specific IPs | [] |
ui.externalTlsTermination | Serve plain HTTP on port 8080 when a load balancer or ingress terminates TLS — see Behind an ingress controller | false |
postgresql.enabled | Use bundled PostgreSQL | true |
postgresql.auth.password | Bundled PostgreSQL password | "" |
postgresql.primary.persistence.storageClass | Storage class for the bundled database volume | "" (cluster default) |
postgresql.image.digest | Bundled PostgreSQL image, pinned to PostgreSQL 18.6.0. Change it only for a deliberate major-version upgrade (back up first) | pinned digest |
externalDatabase.host | External PostgreSQL hostname. Required with postgresql.enabled=false | "" |
externalDatabase.port | External PostgreSQL port | 5432 |
externalDatabase.database | Database name | swift_gw |
externalDatabase.username | Database user | swiftgw |
externalDatabase.password | External PostgreSQL password. Required with postgresql.enabled=false unless existingSecret is set | "" |
externalDatabase.existingSecret | Existing Secret that holds the database password, instead of externalDatabase.password | "" |
externalDatabase.existingSecretKey | Key of the password in that Secret | password |
externalDatabase.jdbcParams | Additional JDBC params (e.g., ?sslmode=require) | "" |
localStorage.enabled | Store files on a PersistentVolume instead of cloud storage (LFS mode) | false |
localStorage.mountPath | Absolute path the volume is mounted at — must be outside /opt/swiftgw | /mnt/data |
localStorage.persistence.size | Size of the PersistentVolumeClaim | 50Gi |
localStorage.persistence.storageClassName | Storage class for the LFS volume. Must support ReadWriteMany — see Local File Storage | standard-rwx |
localStorage.persistence.accessMode | Must be ReadWriteMany when running more than one backend replica | ReadWriteMany |
networkPolicy.enabled | Enable NetworkPolicies | true |
podDisruptionBudget.enabled | Enable PodDisruptionBudgets | true |
Local File Storage
Local File Storage (LFS) mode stores files on a PersistentVolume instead of cloud storage. Enable it with --set localStorage.enabled=true. The chart creates the storagelink-data PVC, mounts it at /mnt/data on every backend replica, and creates the first storage connection pointing at it on first launch.
On GKE the chart defaults the LFS volume to the standard-rwx storage class (Filestore). Enable the Filestore CSI driver on the cluster first, or the class does not exist:
gcloud container clusters update storagelink-gke \
--update-addons=GcpFilestoreCsiDriver=ENABLED \
--zone <zone>
Filestore bills for the instance's provisioned size, which can be larger than the PVC request. standard-rwx is zonal; use enterprise-rwx to survive a zone outage. The Filestore export is owned by root with mode 0755; the backend's fsGroup: 0 makes it writable, so do not remove it. To use a different class, set localStorage.persistence.storageClassName.
The volume must be ReadWriteMany, because both backend replicas read and write the same files. For mount-path rules, see Local File Storage (LFS) Mode.
Upgrading
helm upgrade storagelink ./storagelink \
--namespace storagelink \
--reset-then-reuse-values
--reset-then-reuse-values (Helm 3.14 and later) keeps the values you set at install and applies the new chart's defaults for everything else. Do not use --reuse-values: it keeps the previous release's values in place of the new chart's defaults, so a fix that arrives as a new default (the 0.4.2 request header size, the 0.4.0 PostgreSQL storage class and UI probes) never reaches the install. If you keep your settings in a values file, pass it with -f instead; Helm does not persist --set values between releases.
The generated JWT key, OAuth client, self-signed certificate and PostgreSQL password are kept across upgrades through Helm's lookup, which runs only under the helm CLI. Argo CD renders with helm template, so every sync would generate new ones, sign everyone out and restart the pods. Under GitOps, set security.jwtSecret, security.clientId, security.clientSecret, postgresql.auth.password and ui.tls.* (or ui.externalTlsTermination=true) explicitly.
Upgrading from chart 0.4.1
Chart 0.4.2 keeps StorageLink 1.3.1 and changes only the chart:
helm upgrade storagelink ./storagelink --namespace storagelink --reset-then-reuse-values
Request header size: the backend now accepts headers up to 128KB (
config.maxHttpRequestHeaderSize). The image defaults to 8KB, which Microsoft Entra ID tokens exceed, so OIDC sign-in failed or worked only in a private window. StorageLink 1.4.0 makes 128KB the image default.Self-signed certificate: the generated UI certificate is now valid for 10 years. Charts 0.4.0 and 0.4.1 kept it across upgrades but generated it for one year, so an install made with them would have served an expired certificate a year later. The upgrade keeps your existing certificate; to replace it, remove it from the Secret and upgrade again. The pods restart, and nobody is signed out because the JWT key is unchanged:
kubectl patch secret storagelink-secret -n storagelink --type=json \
-p='[{"op":"remove","path":"/data/tls-certificate"},{"op":"remove","path":"/data/tls-private-key"}]'
helm upgrade storagelink ./storagelink --namespace storagelink --reset-then-reuse-valuesProbe overrides: a
tcpSocket,execorgrpchandler underui.readinessProbeorui.livenessProbenow replaces the defaulthttpGetinstead of being added beside it.
Upgrading from chart 0.4.0
Chart 0.4.1 moves to StorageLink 1.3.1, a security patch release (Tomcat, Jackson and Log4j CVE fixes) with no configuration or database changes. Upgrading replaces the images and changes nothing else:
helm upgrade storagelink ./storagelink --namespace storagelink --reset-then-reuse-values
If you set backend.image.tag or ui.image.tag, change them to 1.3.1 too. From chart 0.3.0, also follow the next section.
Upgrading from chart 0.3.0
With the bundled PostgreSQL, check its major version first. Chart 0.3.0 ran whatever Bitnami's latest image was at install time; 0.4.0 pins PostgreSQL 18.6.0:
kubectl exec -n storagelink storagelink-postgresql-0 -- postgres --version
If it is not 18, do not upgrade as is: set postgresql.image.digest to an image of the major version you run, or back up the database with pg_dump, upgrade, and restore.
Other changes in 0.4.0:
- Licensing:
license.keyis nowlicense.content, passed to the backend asLICENSE_CONTENT. Chart 0.3.0 passed it asLICENSE, which StorageLink ignores, so a 0.3.0 install is licensed only if it was licensed from the admin UI, and that license stays in place. Alicense.key=trialfrom earlier instructions is ignored. - Sign-ins survive upgrades: the generated JWT key, OAuth client and self-signed certificate are now kept, so
helm upgradeno longer signs everyone out. security.jwtSecretshorter than 32 characters now fails the install. Earlier charts accepted it and every sign-in failed.- UI probes: with
ui.externalTlsTermination=true, the probes now use HTTP on port 8080 and the path/index.html. Earlier charts probed HTTPS on 8443, and the UI pods restarted in a loop with no error in their log. In this mode the UI Service port is now namedhttp; an Ingress that refers to it by name must usehttp. - External database: with
postgresql.enabled=false, the install now fails unlessexternalDatabase.hostand eitherexternalDatabase.passwordorexternalDatabase.existingSecretare set. Earlier charts installed, and the backend pods waited forever.
Upgrading from chart 0.2.0
Chart 0.2.0 left the LFS storage class empty, so Kubernetes filled in the cluster's default class when it created the PVC. 0.3.0 and later default to standard-rwx, and a PVC's storage class cannot be changed after it is created. If you enabled localStorage on 0.2.0, helm upgrade fails until you do one of the following:
PVC
Pending(the default class isReadWriteOnce, so it usually never bound): it holds no data, so delete it before upgrading:kubectl delete pvc storagelink-data -n storagelinkPVC
Bound: keep it and pin the class it already has:kubectl get pvc storagelink-data -n storagelink -o jsonpath='{.spec.storageClassName}'
helm upgrade storagelink ./storagelink --namespace storagelink --reset-then-reuse-values \
--set localStorage.persistence.storageClassName=<that-class>
If localStorage was never enabled, upgrade normally.
Uninstalling
helm uninstall storagelink --namespace storagelink
helm uninstall leaves two PersistentVolumeClaims in place, so your data is not deleted:
data-storagelink-postgresql-0— the bundled PostgreSQL database, if usedstoragelink-data— the LFS volume, iflocalStoragewas enabled (the chart marks ithelm.sh/resource-policy: keep)
A reinstall reattaches them only if it uses the same release name (storagelink) and namespace. With the bundled PostgreSQL, also pass the same postgresql.auth.password, or the backend cannot open the kept database.
If you let the chart generate that password, save it before uninstalling, because helm uninstall deletes the Secret that holds it:
kubectl get secret storagelink-postgresql -n storagelink -o jsonpath='{.data.password}' | base64 -d
To remove them once the data is no longer needed:
kubectl delete pvc data-storagelink-postgresql-0 storagelink-data -n storagelink
Deleting the PersistentVolumeClaims permanently deletes the underlying volumes (Persistent Disks, and the LFS file share) and all data stored on them. Back up any important data first.
Troubleshooting
Backend pod CrashLoopBackOff
Check the backend logs:
kubectl logs -n storagelink -l app.kubernetes.io/component=backend --tail=50
Common causes:
- Database connection failed — Verify credentials and that the Cloud SQL instance is running and accessible. Check authorized networks.
- Insufficient memory — The backend requires at least 2Gi of memory.
Backend stuck on "Waiting for changelog lock"
This happens when a previous backend instance crashed during database migrations. The Helm chart includes an init container that, on every pod start, releases a lock held for more than 5 minutes. A newer lock may belong to a replica that is still migrating: wait 5 minutes, then delete the stuck pod.
PVCs stuck in Pending
kubectl get storageclass
kubectl describe pvc -n storagelink
- Bundled PostgreSQL (
data-storagelink-postgresql-0) uses the cluster's default storage class. - LFS volume (
storagelink-data) usesstandard-rwx. If the class is missing or misconfigured, the PVC staysPendingand so do the backend pods — see Local File Storage. If you upgraded from chart 0.2.0, see Upgrading from chart 0.2.0.
UI pod CrashLoopBackOff
Check the UI logs:
kubectl logs -n storagelink -l app.kubernetes.io/component=ui --tail=50
Common causes:
- TLS certificate issue — The chart auto-generates a self-signed certificate. If you provided a custom certificate, verify both
ui.tls.certificateandui.tls.privateKeyare valid PEM and match each other. - Probes failing behind an ingress (chart 0.3.0) — with
ui.externalTlsTermination=true, chart 0.3.0 probed HTTPS on port 8443 and the pods restart with nothing in their log. Upgrade to 0.4.0 or later, or see UI Pods Restart With No Errors in the Log.
Blank page behind an ingress or load balancer
StorageLink must be served from the root of a hostname. Behind a path prefix such as example.com/storagelink, the Admin UI stays blank. Give it its own hostname and route all of / to the UI — see Blank Page Behind an Ingress or Load Balancer.
External IP stuck on \<pending>
kubectl describe svc storagelink-ui -n storagelink
Check the Events section for error messages. Common causes include insufficient project quota or the GKE cluster lacking the necessary permissions to create external load balancers.
Backend pod not becoming Ready
The backend takes 60–90 seconds to start on first launch due to database migrations. Check readiness probe status:
kubectl describe pod -n storagelink -l app.kubernetes.io/component=backend
Resource summary
| Component | Memory (request/limit) | CPU (request/limit) | Storage |
|---|---|---|---|
| PostgreSQL (bundled) | 256Mi / 512Mi | 250m / 500m | 20Gi |
| Backend (x2) | 2Gi / 3Gi | 500m / 1500m | — |
| Admin UI (x2) | 128Mi / 256Mi | 100m / 500m | — |
| Total | ~4.5Gi / ~7Gi | 1450m / 4000m | 20Gi |
