Deploy StorageLink on EKS with Kubernetes Manifests
Purpose: Deploy StorageLink on Amazon EKS with Kustomize-managed manifests and an external Amazon RDS for PostgreSQL database
Requirements: AWS CLI, eksctl, kubectl and the StorageLink container images (provided per your engagement)
Quick Steps:
- Create the EKS cluster and an RDS instance reachable from the nodes on port 5432
- Create the
storagelinknamespace and thestoragelink-pull-secretpull secret - Fill in
secrets.yaml, the RDS endpoint inconfigmap.yamland the image references - Apply the manifests (below) and open
https://<EXTERNAL-IP>of theuiservice
Quick Start:
kubectl apply -f k8s/secrets.yaml
kubectl apply -k k8s/
License: Set LICENSE in secrets.yaml to trial for evaluation or to your license key. If unset, start the 30-day trial or activate a license in the admin UI (Settings → License).
Important: Set loadBalancerSourceRanges in ui.yaml before deploying — the first visitor to the admin UI can create the admin account
Product: StorageLink by Thorn Technologies — cloud storage gateway for secure file sharing
Overview
This guide walks through deploying StorageLink on Amazon Elastic Kubernetes Service (EKS) using plain Kubernetes manifests, managed by Kustomize. This is an alternative to the Helm chart deployment — use this approach if your organization prefers raw manifests or does not use Helm.
This deployment uses an external Amazon RDS for PostgreSQL instance (no in-cluster database). The manifests include security hardening out of the box: NetworkPolicies, ServiceAccounts with token automount disabled, non-root containers, and IP-restricted load balancers.
For detailed configuration options (TLS modes, client IP preservation, security context, LFS mode, image verification, and more), see the Container Deployment Reference.
Architecture
┌──────────────────────────────────────────────┐
│ Amazon Elastic Kubernetes Service (EKS) │
│ Namespace: storagelink │
│ │
HTTPS (443) │ ┌────────────┐ ┌────────────────┐ │
──────────────────┼──►│ Admin UI │───►│ Backend │ │
│ │ (Nginx) │ │ (Spring Boot) │ │
│ │ Port 8443 │ │ Port 8080 API │ │
│ └────────────┘ └───────┬────────┘ │
│ │ │
└─────────────────────────────┼────────────────┘
│
┌───────▼─────────┐
│ Amazon RDS for │
│ PostgreSQL │
│ │
└─────────────────┘
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 — AWS-managed database storing user accounts and configuration data.
Prerequisites
- An AWS account
- AWS CLI installed and configured
- eksctl installed
- kubectl installed (includes Kustomize since v1.14+)
- Network access to Docker Hub, where the StorageLink images are published:
thorntech/storagelink-backendandthorntech/storagelink-ui
Deployment files
The deployment consists of Kubernetes manifest files managed by Kustomize:
| File | Description |
|---|---|
kustomization.yaml | Kustomize orchestration — sets namespace, labels, and resource order |
namespace.yaml | Creates the storagelink namespace |
serviceaccount.yaml | Dedicated ServiceAccounts with token automount disabled |
secrets.yaml | Template for database credentials, OAuth secrets, JWT key, license, and TLS certificate |
configmap.yaml | Non-sensitive configuration for the backend and UI |
networkpolicy.yaml | NetworkPolicies restricting pod-to-pod and egress traffic |
backend.yaml | Backend Deployment and ClusterIP Service |
ui.yaml | Admin UI Deployment and LoadBalancer Service (with IP restrictions) |
components/lfs/ | Optional Kustomize component for Local File Storage mode — adds a ReadWriteMany PVC (efs-sc) mounted at /mnt/data. Disabled by default. |
components/openshift/ | Optional Kustomize component for OpenShift — removes the hard-coded user and group IDs. Disabled by default. |
Step 1: Create an EKS cluster
eksctl create cluster \
--name storagelink-eks \
--region us-east-1 \
--node-type t3.medium \
--nodes 2 \
--managed
This takes approximately 15 minutes. eksctl automatically creates the VPC, subnets, IAM roles, and node group.
Verify the cluster is ready:
kubectl get nodes
Step 2: Create the RDS PostgreSQL database
# Create the RDS instance
aws rds create-db-instance \
--db-instance-identifier storagelink-pg \
--db-instance-class db.t3.micro \
--engine postgres \
--engine-version 16 \
--master-username swiftgw \
--master-user-password '<your-strong-password>' \
--allocated-storage 20 \
--db-name swift_gw \
--no-multi-az \
--region us-east-1
# Wait for the instance to become available
aws rds wait db-instance-available \
--db-instance-identifier storagelink-pg \
--region us-east-1
# Get the endpoint
aws rds describe-db-instances \
--db-instance-identifier storagelink-pg \
--region us-east-1 \
--query 'DBInstances[0].Endpoint.Address' \
--output text
Configure network access
The RDS instance must be reachable from your EKS nodes on port 5432. If they are in the same VPC, add the EKS node security group to the RDS security group's inbound rules. If they are in different VPCs (the default with eksctl), either:
- Set up VPC peering between the EKS and RDS VPCs (recommended)
- Create the RDS instance in the same VPC and subnets that
eksctlcreated for EKS
Consult your network team for the approach that best fits your organization's security requirements.
Step 3: Image access
The images are public on Docker Hub, and the manifests already reference them (thorntech/storagelink-backend:1.3.0 and thorntech/storagelink-ui:1.3.0), so no pull secret is needed. To pull from your own registry or mirror instead, see 4c.
Step 4: Configure the manifests
4a. Update secrets
The secrets.yaml file is a template with REPLACE_ME placeholders. Fill in the values and apply it separately (it is excluded from Kustomize to prevent accidental commits of real credentials).
Generate secure values:
# Database password
openssl rand -base64 32
# OAuth client ID and secret (must match between backend and UI secrets)
openssl rand -hex 16
# JWT secret
openssl rand -base64 32
Generate a self-signed TLS certificate for testing:
openssl req -x509 -newkey rsa:2048 -keyout key.pem -out cert.pem -days 365 -nodes \
-subj "/CN=storagelink/O=YourOrg"
Update secrets.yaml with your values:
# storagelink-db-secret
stringData:
POSTGRES_USER: swiftgw
POSTGRES_PASSWORD: <generated-password> # Must match RDS password
POSTGRES_DB: swift_gw
# storagelink-backend-secret
stringData:
SPRING_DATASOURCE_USERNAME: swiftgw
SPRING_DATASOURCE_PASSWORD: <generated-password> # Must match above
SECURITY_CLIENT_ID: <generated-client-id> # Must match UI secret
SECURITY_CLIENT_SECRET: <generated-client-secret> # Must match UI secret
SECURITY_JWT_SECRET: <generated-jwt-secret>
LICENSE: <your-license-key> # "trial" for evaluation, or your license key. If unset, license the deployment from the admin UI (Settings → License)
# storagelink-ui-secret
stringData:
SECURITY_CLIENT_ID: <generated-client-id> # Must match backend
SECURITY_CLIENT_SECRET: <generated-client-secret> # Must match backend
WEBSITE_BUNDLE_CRT: |
-----BEGIN CERTIFICATE-----
<paste cert.pem contents>
-----END CERTIFICATE-----
WEBSITE_KEY: |
-----BEGIN PRIVATE KEY-----
<paste key.pem contents>
-----END PRIVATE KEY-----
For production, use AWS Secrets Manager with the Secrets Store CSI Driver to manage secrets outside of Kubernetes manifests.
4b. Update ConfigMap
Edit configmap.yaml and replace the PostgreSQL hostname placeholder with your RDS endpoint:
# storagelink-backend-config
data:
DB_HOST: storagelink-pg.xxxxxxxxxxxx.us-east-1.rds.amazonaws.com
SPRING_DATASOURCE_URL: jdbc:postgresql://storagelink-pg.xxxxxxxxxxxx.us-east-1.rds.amazonaws.com:5432/swift_gw?sslmode=require
4c. Container images (optional)
backend.yaml and ui.yaml pull StorageLink 1.3.0 from Docker Hub. Change the image: lines only to use a different version or your own registry or mirror. If that registry needs credentials, create a pull secret and add it to both Deployments under spec.template.spec:
imagePullSecrets:
- name: storagelink-pull-secret
4d. Configure Admin UI access restrictions
The Admin UI should be restricted to authorized IP addresses. Edit ui.yaml and configure the Service with loadBalancerSourceRanges:
spec:
type: LoadBalancer
loadBalancerSourceRanges:
- "203.0.113.50/32" # Replace with your admin's IP
- "198.51.100.0/24" # Replace with your office network (optional)
The first person to reach the Admin UI can create the admin account. Always restrict access via loadBalancerSourceRanges before deploying.
Step 5: Deploy
Deploy the manifests first. This also creates the storagelink namespace:
kubectl apply -k k8s/
Then apply the secrets (they are excluded from Kustomize so real credentials are never committed):
kubectl apply -f k8s/secrets.yaml
Until the secrets exist, the pods wait in CreateContainerConfigError; they start on their own once the secrets are applied.
Monitor the deployment:
kubectl get pods -n storagelink -w
A healthy deployment looks like:
NAME READY STATUS RESTARTS AGE
backend-xxxxx 1/1 Running 0 2m
backend-yyyyy 1/1 Running 0 2m
ui-xxxxx 1/1 Running 0 2m
ui-yyyyy 1/1 Running 0 2m
The backend may take 60–90 seconds to become ready due to database migrations on first launch.
Step 6: Access the Admin UI
Get the external hostname of the UI service:
kubectl get svc ui -n storagelink
It may take 1–2 minutes for AWS to provision the load balancer. Once available:
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
ui LoadBalancer 10.100.29.73 a71865...us-east-1.elb.amazonaws.com 443:30749/TCP 2m
Open https://<EXTERNAL-IP> in your browser. You will see a certificate warning because the deployment uses a self-signed TLS certificate — this is expected for testing.
You will be prompted to create your admin account on first access via the setup wizard.
Optional components
Enable a component by uncommenting it under components: in k8s/kustomization.yaml, then re-run kubectl apply -k k8s/.
Local File Storage (optional)
components/lfs stores files on a PersistentVolume instead of cloud storage. It adds the storagelink-data-pvc PVC, mounts it at /mnt/data on every backend replica, and creates the first storage connection pointing at it on first launch. The volume must be ReadWriteMany, because both backend replicas read and write the same files.
The component's PVC uses the efs-sc storage class (Amazon EFS), which EKS does not create for you. Create an EFS file system with mount targets in your nodes' subnets (security group allowing NFS, TCP 2049, from the nodes), install the Amazon EFS CSI driver, then create the class. It must set provisioningMode, or the PVC stays Pending with Missing provisioningMode parameter:
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: efs-sc
provisioner: efs.csi.aws.com
parameters:
provisioningMode: efs-ap # one EFS access point per PVC
fileSystemId: fs-0123456789abcdef0
directoryPerms: "700" # the access point's POSIX user owns the directory
kubectl delete -k k8s/ deletes the LFS PVC, and with the default Delete reclaim policy the files go with it. For production data, use a storage class with reclaimPolicy: Retain.
OpenShift (optional)
OpenShift's restricted-v2 SecurityContextConstraint assigns user and group IDs from the namespace's range and rejects pods that set their own. components/openshift removes the hard-coded IDs so the platform assigns them. Use it only on OpenShift: on EKS the IDs are required (the fsGroup makes block volumes writable).
Initial admin account
On first access, StorageLink displays a setup wizard that lets you create the initial admin account. To prevent unauthorized access to the wizard, restrict the Admin UI to trusted IP addresses using loadBalancerSourceRanges on the UI Service.
Security features
The manifests include these security defaults:
- Non-root containers — the backend runs as the image's own user (UID 1000, group 0; the manifests do not pin
runAsUser), the UI as UID 1000 - Read-only root filesystem — enabled on the backend container
- Dropped capabilities — all Linux capabilities are dropped
- No privilege escalation —
allowPrivilegeEscalation: falseon all containers - NetworkPolicies — restrict pod-to-pod and egress traffic (DNS, PostgreSQL, HTTPS for cloud storage)
- ServiceAccounts — dedicated accounts with
automountServiceAccountToken: false - Pod anti-affinity — backend and UI replicas prefer spreading across nodes
- IP-restricted Admin UI —
loadBalancerSourceRangeson the LoadBalancer service - 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
Replace the self-signed certificate by updating WEBSITE_BUNDLE_CRT and WEBSITE_KEY in storagelink-ui-secret with your CA-signed certificate and key.
For production, consider using an Ingress controller with cert-manager for automated certificate management.
Configuration reference
Backend ConfigMap (storagelink-backend-config)
| Variable | Description | Example |
|---|---|---|
SPRING_PROFILES_ACTIVE | Spring profile | container,json-logs |
LOGGING_LEVEL_ROOT | Log level | INFO |
DB_HOST | PostgreSQL FQDN | storagelink-pg.xxxx.us-east-1.rds.amazonaws.com |
SPRING_DATASOURCE_URL | Full JDBC URL | jdbc:postgresql://host:5432/swift_gw?sslmode=require |
FEATURES_INSTANCE_CLOUD_PROVIDER | Cloud provider hint | aws |
UI ConfigMap (storagelink-ui-config)
| Variable | Description | Example |
|---|---|---|
BACKEND_URL | Backend API endpoint (must include trailing slash) | http://backend:8080/ |
CLOUD_PROVIDER | Cloud provider hint | aws |
Resource summary
| Component | Memory (request/limit) | CPU (request/limit) | Storage |
|---|---|---|---|
| Backend (x2) | 2Gi / 3Gi | 500m / 1500m | — |
| Admin UI (x2) | 128Mi / 256Mi | 100m / 500m | — |
| Total | ~4.3Gi / ~6.5Gi | 1200m / 3500m | — |
The Amazon RDS for PostgreSQL resources are managed separately by AWS.
Troubleshooting
Backend pod CrashLoopBackOff
Check the backend logs:
kubectl logs -n storagelink -l app=backend --tail=50
Common causes:
- Database connection failed — Verify credentials match between
storagelink-db-secret,storagelink-backend-secret, and the RDS instance. Check that the RDS security group allows inbound traffic from the EKS nodes on port 5432. - 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 init container clears stale locks automatically on every pod start. If the issue persists:
# Scale backend to 0
kubectl scale deployment/backend -n storagelink --replicas=0
# Clear the lock manually
kubectl run -n storagelink pg-fix --rm -it --restart=Never \
--image=postgres:16-alpine --env="PGPASSWORD=<password>" \
-- psql -h <rds-endpoint> -U swiftgw -d swift_gw \
-c "UPDATE databasechangeloglock SET locked=false, lockgranted=null, lockedby=null WHERE id=1;"
# Scale backend back up
kubectl scale deployment/backend -n storagelink --replicas=1
UI pod CrashLoopBackOff
Check the UI logs:
kubectl logs -n storagelink -l app=ui --tail=50
Common causes:
- Invalid TLS certificate — Verify both
WEBSITE_BUNDLE_CRTandWEBSITE_KEYare valid PEM and match each other. Regenerate both together if needed. - Certificate/key mismatch — If you see
SSL_CTX_use_PrivateKey ... key values mismatch, the private key does not correspond to the certificate.
ImagePullBackOff
kubectl describe pod -n storagelink -l app=backend
Check the Events section. With the default Docker Hub images, make sure the nodes can reach registry-1.docker.io (NAT or egress rules) and that the tag exists. Anonymous Docker Hub pulls are rate-limited; if you hit the limit, use a registry mirror. If you changed the images to a private registry, check that the pull secret exists in the storagelink namespace and is listed under imagePullSecrets.
External IP stuck on \<pending>
kubectl describe svc ui -n storagelink
Check the Events section for error messages. On EKS, the LoadBalancer creates a Classic Load Balancer (ELB) by default. Ensure your EKS node security group allows inbound traffic on the NodePort range.
Uninstalling
# Remove secrets
kubectl delete -f k8s/secrets.yaml
# Remove all other resources
kubectl delete -k k8s/
If components/lfs is enabled, this also deletes the LFS PVC and, with the default Delete reclaim policy, the files on it. Back them up first.
To also remove the RDS instance:
aws rds delete-db-instance \
--db-instance-identifier storagelink-pg \
--skip-final-snapshot \
--region us-east-1
Manifest files
Create a k8s/ directory and add the following files. The step-by-step instructions above explain which values to customize.
k8s/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: storagelink
labels:
- pairs:
app.kubernetes.io/name: storagelink
app.kubernetes.io/version: "1.3.0"
resources:
- namespace.yaml
- serviceaccount.yaml
# secrets.yaml is a template — apply real secrets separately via kubectl
- configmap.yaml
- networkpolicy.yaml
- backend.yaml
- ui.yaml
# Local File Storage (LFS) mode is off by default — the backend is stateless and
# stores files in cloud storage. To store files on a PersistentVolume instead,
# uncomment the component below. It adds a ReadWriteMany PVC, mounts it at
# /mnt/data, and pre-configures the first storage connection to use it.
#
# The base prefix MUST be an absolute path outside /opt/swiftgw. A relative path
# is accepted but does not work: the backend cannot create its directory under
# the read-only root filesystem (see components/lfs/kustomization.yaml).
#
# On OpenShift, also add components/openshift. It removes every hard-coded
# UID, GID and fsGroup so the restricted-v2 SCC can assign them. Do not use it
# on EKS/AKS/GKE: RWO block volumes (Azure Disk, EBS, PD) need fsGroup: 0.
#
# components:
# - components/lfs
# - components/openshift
k8s/namespace.yaml
apiVersion: v1
kind: Namespace
metadata:
name: storagelink
labels:
pod-security.kubernetes.io/enforce: restricted
---
apiVersion: v1
kind: ResourceQuota
metadata:
name: storagelink-quota
spec:
hard:
requests.cpu: "4"
requests.memory: "8Gi"
limits.cpu: "8"
limits.memory: "16Gi"
pods: "20"
---
apiVersion: v1
kind: LimitRange
metadata:
name: storagelink-limits
spec:
limits:
- default:
cpu: "500m"
memory: "512Mi"
defaultRequest:
cpu: "100m"
memory: "128Mi"
type: Container
k8s/serviceaccount.yaml
apiVersion: v1
kind: ServiceAccount
metadata:
name: storagelink-backend
automountServiceAccountToken: false
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: storagelink-ui
automountServiceAccountToken: false
k8s/secrets.yaml
# DO NOT commit this file with real values — it is in .gitignore.
# This is a template. Fill in values and apply with: kubectl apply -f secrets.yaml
#
# Generate secure values:
# Password: openssl rand -base64 32
# Client ID: openssl rand -hex 16
# Client Secret: openssl rand -hex 16
# JWT Secret: openssl rand -base64 32
#
# For production, use AWS Secrets Manager with the Secrets Store CSI Driver:
# https://docs.aws.amazon.com/secretsmanager/latest/userguide/integrating_csi_driver.html
apiVersion: v1
kind: Secret
metadata:
name: storagelink-db-secret
namespace: storagelink
type: Opaque
stringData:
POSTGRES_USER: "REPLACE_ME"
POSTGRES_PASSWORD: "REPLACE_ME" # openssl rand -base64 32
POSTGRES_DB: "REPLACE_ME"
---
apiVersion: v1
kind: Secret
metadata:
name: storagelink-backend-secret
namespace: storagelink
type: Opaque
stringData:
SPRING_DATASOURCE_USERNAME: "REPLACE_ME" # must match POSTGRES_USER
SPRING_DATASOURCE_PASSWORD: "REPLACE_ME" # must match POSTGRES_PASSWORD
SECURITY_CLIENT_ID: "REPLACE_ME" # openssl rand -hex 16 — must match UI secret
SECURITY_CLIENT_SECRET: "REPLACE_ME" # openssl rand -hex 16 — must match UI secret
SECURITY_JWT_SECRET: "REPLACE_ME" # openssl rand -base64 32
LICENSE: "REPLACE_ME" # "trial" for evaluation, or your license key. If unset, license the deployment from the admin UI (Settings → License)
---
apiVersion: v1
kind: Secret
metadata:
name: storagelink-ui-secret
namespace: storagelink
type: Opaque
stringData:
SECURITY_CLIENT_ID: "REPLACE_ME" # must match backend secret
SECURITY_CLIENT_SECRET: "REPLACE_ME" # must match backend secret
WEBSITE_BUNDLE_CRT: "REPLACE_ME" # PEM-encoded certificate
WEBSITE_KEY: "REPLACE_ME" # PEM-encoded private key
k8s/configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: storagelink-backend-config
data:
SPRING_PROFILES_ACTIVE: container,json-logs
LOGGING_LEVEL_ROOT: INFO
DB_HOST: REPLACE_WITH_PG_FQDN
SPRING_DATASOURCE_URL: jdbc:postgresql://REPLACE_WITH_PG_FQDN:5432/swift_gw?sslmode=require
SERVER_PORT: "8080"
FEATURES_INSTANCE_CLOUD_PROVIDER: aws
FEATURES_SYSTEMD_NOTIFY: "false"
FEATURES_SFTP_SUBSYSTEM_AUTO_START: "false"
---
apiVersion: v1
kind: ConfigMap
metadata:
name: storagelink-ui-config
data:
BACKEND_URL: "http://backend:8080/"
CLOUD_PROVIDER: aws
k8s/networkpolicy.yaml
# NetworkPolicies restrict pod-to-pod and egress traffic.
# Without these, any pod in the cluster can reach any other pod.
# Backend: only accept traffic from UI pods on port 8080
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: backend-netpol
spec:
podSelector:
matchLabels:
app: backend
policyTypes:
- Ingress
- Egress
ingress:
- from:
- podSelector:
matchLabels:
app: ui
ports:
- protocol: TCP
port: 8080
egress:
# Allow DNS resolution
- to: []
ports:
- protocol: UDP
port: 53
- protocol: TCP
port: 53
# Allow connection to RDS PostgreSQL (port 5432)
- to: []
ports:
- protocol: TCP
port: 5432
# Allow HTTPS egress for cloud storage connections (AWS S3, Azure Blob, GCS, etc.)
- to: []
ports:
- protocol: TCP
port: 443
---
# UI: accept traffic from the LoadBalancer on port 443, talk to backend on 8080
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: ui-netpol
spec:
podSelector:
matchLabels:
app: ui
policyTypes:
- Ingress
- Egress
ingress:
- ports:
- protocol: TCP
port: 8443
egress:
# Allow DNS resolution
- to: []
ports:
- protocol: UDP
port: 53
- protocol: TCP
port: 53
# Allow connection to backend
- to:
- podSelector:
matchLabels:
app: backend
ports:
- protocol: TCP
port: 8080
k8s/backend.yaml
apiVersion: v1
kind: Service
metadata:
name: backend
spec:
type: ClusterIP
selector:
app: backend
ports:
- name: http
port: 8080
targetPort: 8080
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: backend-pdb
spec:
maxUnavailable: 1
selector:
matchLabels:
app: backend
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: backend
spec:
replicas: 2
selector:
matchLabels:
app: backend
template:
metadata:
labels:
app: backend
spec:
serviceAccountName: storagelink-backend
securityContext:
runAsNonRoot: true
# GID 0 matches the group-0 setgid ownership of /opt/swiftgw in the image.
# fsGroup also fixes up volume ownership, without which a PVC that arrives
# root:root 0755 (RWO block volumes: Azure Disk, EBS, PD) is unwritable by
# the backend. Required on EKS/AKS/GKE; components/openshift removes it.
fsGroup: 0
# Only chown the volume when its root ownership is wrong, instead of
# recursively on every pod start (slow on a large LFS volume).
fsGroupChangePolicy: OnRootMismatch
seccompProfile:
type: RuntimeDefault
affinity:
podAntiAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
podAffinityTerm:
labelSelector:
matchLabels:
app: backend
topologyKey: kubernetes.io/hostname
initContainers:
- name: wait-for-db
image: postgres:16-alpine
securityContext:
runAsUser: 70 # postgres user in alpine image
runAsNonRoot: true
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
env:
- name: DB_HOST
valueFrom:
configMapKeyRef:
name: storagelink-backend-config
key: DB_HOST
envFrom:
- secretRef:
name: storagelink-db-secret
command:
- sh
- -c
- |
until pg_isready -h "$DB_HOST" -p 5432 -U "$POSTGRES_USER"; do
echo "Waiting for PostgreSQL..."
sleep 2
done
echo "PostgreSQL is ready"
echo "Clearing any stale Liquibase lock..."
PGPASSWORD="$POSTGRES_PASSWORD" psql -h "$DB_HOST" -U "$POSTGRES_USER" -d "$POSTGRES_DB" \
-c "UPDATE databasechangeloglock SET locked=false, lockgranted=null, lockedby=null WHERE id=1;" 2>/dev/null || true
containers:
- name: backend
image: thorntech/storagelink-backend:1.3.0
imagePullPolicy: Always
securityContext:
# Deliberately no runAsUser: the image owns the UID (USER 1000:0).
# SWIFT-1450 removed the swiftgw user so the image runs under an
# arbitrary UID; do not re-pin it here. Only safe because the
# Dockerfiles use a numeric USER; a named user fails runAsNonRoot
# admission with "cannot verify user is non-root". SCCs have no
# runAsGroup strategy; components/openshift removes it with the other
# IDs so the platform assigns all of them.
runAsGroup: 0
runAsNonRoot: true
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
ports:
- containerPort: 8080
envFrom:
- secretRef:
name: storagelink-backend-secret
- configMapRef:
name: storagelink-backend-config
resources:
requests:
memory: "2Gi"
cpu: "500m"
limits:
memory: "3Gi"
cpu: "1500m"
volumeMounts:
- name: tmp
mountPath: /tmp
startupProbe:
httpGet:
path: /actuator/health
port: 8080
initialDelaySeconds: 60
periodSeconds: 10
failureThreshold: 30
readinessProbe:
httpGet:
path: /actuator/health
port: 8080
periodSeconds: 10
livenessProbe:
httpGet:
path: /actuator/health
port: 8080
initialDelaySeconds: 180
periodSeconds: 20
volumes:
- name: tmp
emptyDir: {}
k8s/ui.yaml
apiVersion: v1
kind: Service
metadata:
name: ui
spec:
type: LoadBalancer
# Local preserves client IP; Cluster allows cross-node balancing but SNATs the source.
externalTrafficPolicy: Local
loadBalancerSourceRanges:
- "REPLACE_WITH_YOUR_CIDR" # e.g. "203.0.113.0/24" — restrict to your network
selector:
app: ui
ports:
- name: https
port: 443
targetPort: 8443
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: ui-pdb
spec:
maxUnavailable: 1
selector:
matchLabels:
app: ui
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: ui
spec:
replicas: 2
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
selector:
matchLabels:
app: ui
template:
metadata:
labels:
app: ui
spec:
serviceAccountName: storagelink-ui
securityContext:
runAsNonRoot: true
seccompProfile:
type: RuntimeDefault
affinity:
podAntiAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
podAffinityTerm:
labelSelector:
matchLabels:
app: ui
topologyKey: kubernetes.io/hostname
containers:
- name: ui
image: thorntech/storagelink-ui:1.3.0
imagePullPolicy: Always
securityContext:
runAsUser: 1000 # image default user that owns writable directories
runAsNonRoot: true
allowPrivilegeEscalation: false
readOnlyRootFilesystem: false # UI entrypoint writes to /etc/nginx/templates, /etc/nginx/conf.d, /var/run/swiftgw-webconfig
capabilities:
drop: ["ALL"]
ports:
- containerPort: 8443
envFrom:
- secretRef:
name: storagelink-ui-secret
- configMapRef:
name: storagelink-ui-config
resources:
requests:
memory: "128Mi"
cpu: "100m"
limits:
memory: "256Mi"
cpu: "500m"
volumeMounts:
- name: tmp
mountPath: /tmp
readinessProbe:
httpGet:
path: /
port: 8443
scheme: HTTPS
initialDelaySeconds: 5
periodSeconds: 10
livenessProbe:
httpGet:
path: /
port: 8443
scheme: HTTPS
initialDelaySeconds: 10
periodSeconds: 20
volumes:
- name: tmp
emptyDir: {}
k8s/components/lfs/kustomization.yaml
Only needed for Local File Storage.
apiVersion: kustomize.config.k8s.io/v1alpha1
kind: Component
# Local File Storage (LFS) mode — stores files on a PersistentVolume instead of
# cloud storage. Opt in by adding this to the top-level kustomization.yaml:
#
# components:
# - components/lfs
#
# The mount path MUST be an absolute path outside the application home
# (/opt/swiftgw). A relative path is accepted, but the backend cannot create
# its directory under the read-only root filesystem: it logs the error and
# first launch creates a connection to a directory that does not exist. See
# the Container Reference KB article.
#
# WARNING: `kubectl delete -k` deletes the PVC (directly, and again with the
# namespace), and the cloud default reclaim policy is Delete, so the files go
# with it. Unlike Helm there is no keep annotation. For production data use a
# StorageClass with reclaimPolicy: Retain.
resources:
- pvc.yaml
patches:
# Pre-configure the first storage connection as LFS.
- target:
kind: ConfigMap
name: storagelink-backend-config
patch: |-
apiVersion: v1
kind: ConfigMap
metadata:
name: storagelink-backend-config
data:
FEATURES_FIRST_CONNECTION_CLOUD_PROVIDER: "lfs"
FEATURES_FIRST_CONNECTION_BASE_PREFIX: "/mnt/data"
# Mount the PVC on the backend pods.
- target:
kind: Deployment
name: backend
patch: |-
apiVersion: apps/v1
kind: Deployment
metadata:
name: backend
spec:
template:
spec:
containers:
- name: backend
volumeMounts:
- name: data
mountPath: /mnt/data
volumes:
- name: data
persistentVolumeClaim:
claimName: storagelink-data-pvc
k8s/components/lfs/pvc.yaml
Only needed for Local File Storage.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: storagelink-data-pvc
spec:
# ReadWriteMany is required: the backend runs 2 replicas and every pod must
# see the same files. A ReadWriteOnce volume (e.g. Azure Disk / managed-csi)
# binds to a single node and AZ and will not work here.
accessModes:
- ReadWriteMany
# Amazon EFS. NOT preinstalled on EKS: install the EFS CSI driver and create
# an "efs-sc" StorageClass first, or the PVC stays Pending. It needs
# provisioningMode (a class with only fileSystemId fails with "Missing
# provisioningMode parameter"):
# apiVersion: storage.k8s.io/v1
# kind: StorageClass
# metadata: {name: efs-sc}
# provisioner: efs.csi.aws.com
# parameters:
# provisioningMode: efs-ap # one EFS access point per PVC
# fileSystemId: fs-0123456789abcdef0
# directoryPerms: "700" # the access point's POSIX user owns it
# Other clouds: Azure azurefile, GCP standard-rwx.
storageClassName: efs-sc
resources:
requests:
storage: 50Gi
k8s/components/openshift/kustomization.yaml
Only needed on OpenShift.
apiVersion: kustomize.config.k8s.io/v1alpha1
kind: Component
# OpenShift restricted-v2 SCC compatibility. The SCC rejects any fsGroup or
# runAsUser outside the namespace's preallocated range, so this removes every
# hard-coded ID and lets the SCC assign them. Removing only some of them still
# fails admission. Opt in on OpenShift only, by adding this to the top-level
# kustomization.yaml:
#
# components:
# - components/openshift
#
# Equivalent to the Helm chart's
# global.compatibility.openshift.adaptSecurityContext=force.
patches:
- target:
kind: Deployment
name: backend
patch: |-
- op: remove
path: /spec/template/spec/securityContext/fsGroup
- op: remove
path: /spec/template/spec/initContainers/0/securityContext/runAsUser
- op: remove
path: /spec/template/spec/containers/0/securityContext/runAsGroup
- target:
kind: Deployment
name: ui
patch: |-
- op: remove
path: /spec/template/spec/containers/0/securityContext/runAsUser
