Container Deployment Reference
This is the comprehensive reference for deploying StorageLink as containers. It covers every configuration option, security context, networking, TLS, and troubleshooting.
Looking for a step-by-step deployment guide? See the cloud-specific recipes:
| Cloud | Helm Chart | Kubernetes Manifests |
|---|---|---|
| AWS | Deploy with Helm on EKS | Deploy on EKS with K8s Manifests |
| Azure | Deploy with Helm on AKS | Deploy on AKS with K8s Manifests |
| GCP | Deploy with Helm on GKE | Deploy on GKE with K8s Manifests |
Components
StorageLink consists of two container images that work together:
| Image | Description |
|---|---|
| storagelink-backend | Core API server (Spring Boot). Handles cloud storage operations and user management. |
| storagelink-ui | Web-based administration dashboard (Nginx). Provides the management interface for configuring storage connections and managing users. |
A PostgreSQL database (16 or later) is also required.
Image Access
Container images are delivered per your engagement — contact your Thorn Technologies representative for registry credentials and image references.
Ports
Backend
| Port | Protocol | Description |
|---|---|---|
| 8080 | TCP | REST API (HTTP) |
UI
| Port | Protocol | Description |
|---|---|---|
| 8080 | TCP | HTTP (used when EXTERNAL_TLS_TERMINATION=true) |
| 8443 | TCP | HTTPS (default, in-pod TLS termination) |
Configuration
Backend Environment Variables
| Variable | Description | Required |
|---|---|---|
SPRING_DATASOURCE_URL | PostgreSQL JDBC URL (e.g., jdbc:postgresql://db:5432/swift_gw?sslmode=require) | Yes |
SPRING_DATASOURCE_USERNAME | Database username | Yes |
SPRING_DATASOURCE_PASSWORD | Database password | Yes |
SECURITY_CLIENT_ID | OAuth client ID — must match UI | Yes |
SECURITY_CLIENT_SECRET | OAuth client secret — must match UI | Yes |
SECURITY_JWT_SECRET | JWT signing secret | Yes |
LICENSE | StorageLink license key (use trial for evaluation) | Yes |
SERVER_PORT | API server port (default: 8080) | No |
SPRING_PROFILES_ACTIVE | Spring profiles (use container,json-logs for containerized deployments) | No |
FEATURES_INSTANCE_CLOUD_PROVIDER | Cloud provider hint: azure, aws, or gcp | No |
FEATURES_SYSTEMD_NOTIFY | Set to false in containers (systemd is not present) | No |
FEATURES_SFTP_SUBSYSTEM_AUTO_START | Set to false (StorageLink does not run an in-container SFTP subsystem) | No |
FEATURES_FIRST_CONNECTION_CLOUD_PROVIDER | Pre-configure the first storage connection: s3, azureblob, gcp, or lfs | No |
FEATURES_FIRST_CONNECTION_NAME | Display name for the pre-configured connection | No |
FEATURES_FIRST_CONNECTION_BASE_PREFIX | Base path or bucket prefix for the first connection | No |
SECURITY_CLIENTIP_TRUSTEDPROXIES | Trusted proxy CIDRs for client IP resolution (see Trusted Proxy Configuration) | No |
JAVA_OPTS | JVM tuning options (e.g., -Xms2g -Xmx6g) | No |
UI Environment Variables
| Variable | Description | Required |
|---|---|---|
BACKEND_URL | URL to the backend API (e.g., http://backend:8080/) | Yes |
SECURITY_CLIENT_ID | OAuth client ID — must match backend | Yes |
SECURITY_CLIENT_SECRET | OAuth client secret — must match backend | Yes |
CLOUD_PROVIDER | Cloud provider hint for the UI | No |
WEBSITE_BUNDLE_CRT | TLS certificate in PEM format (see TLS Configuration) | Conditional |
WEBSITE_KEY | TLS private key in PEM format | Conditional |
EXTERNAL_TLS_TERMINATION | Skip in-pod TLS when a load balancer terminates TLS (see TLS Configuration) | No |
Credential Generation
Generate the required security credentials before deploying:
SECURITY_CLIENT_ID=$(openssl rand -hex 16)
SECURITY_CLIENT_SECRET=$(openssl rand -hex 16)
SECURITY_JWT_SECRET=$(openssl rand -base64 32)
The client ID and client secret must be identical in both the backend and UI configurations.
Initial Admin Account
On first launch, StorageLink presents a signup page that allows anyone who can reach the UI to create the initial admin account. Once an admin exists, this signup page is permanently disabled.
If the UI is exposed via a LoadBalancer or public IP without IP restrictions, an unauthorized user could create the admin account before you do. To prevent this, use one of the following approaches:
- Pre-configure admin credentials at deploy time (recommended for Helm) — the Helm chart's
admin.usernameandadmin.passwordvalues trigger a post-install Job that creates the admin automatically. - Restrict network access — use
loadBalancerSourceRangesto limit who can reach the UI during initial setup. - Use
ClusterIP+ port-forward — keep the UI internal and access it viakubectl port-forwardfor initial configuration.
Helm Chart
helm install storagelink ./storagelink \
--namespace storagelink --create-namespace \
--set admin.username=myadmin \
--set admin.password='MySecurePassword1!'
The admin password must meet StorageLink's password policy (minimum length, complexity requirements).
Docker Compose / Manual Deployments
For non-Helm deployments, create the admin account via the backend API immediately after startup:
# Wait for the backend to be ready
until curl -sf http://localhost:8080/actuator/health > /dev/null; do sleep 2; done
# Create the admin account (only works when no admin exists)
curl -X POST http://localhost:8080/1.0.0/admin/config \
-H "Content-Type: application/json" \
-d '{"username": "myadmin", "password": "MySecurePassword1!"}'
The POST /1.0.0/admin/config endpoint returns 201 Created on success and 404 Not Found if an admin already exists.
TLS Configuration
The UI container supports two TLS modes:
In-Pod TLS (Default)
The UI terminates TLS internally on port 8443. Provide a certificate and private key via environment variables:
WEBSITE_BUNDLE_CRT="<PEM-encoded certificate>"
WEBSITE_KEY="<PEM-encoded private key>"
If you're using a certificate chain, include all certificates in WEBSITE_BUNDLE_CRT with the server certificate first, followed by intermediate certificates.
To generate a self-signed certificate for testing:
openssl req -x509 -newkey rsa:2048 -keyout tls.key -out tls.crt \
-days 365 -nodes -subj "/CN=storagelink"
Entrypoint fail-fast behaviors: The UI entrypoint validates TLS configuration at startup:
- If only one of
WEBSITE_BUNDLE_CRT/WEBSITE_KEYis provided (half-delivery), the container exits with an error. - If the path points to a directory instead of a file, the container exits with an error.
External TLS Termination
When a load balancer, ingress controller, or reverse proxy terminates TLS upstream, set:
EXTERNAL_TLS_TERMINATION=true
In this mode, the UI listens on port 8080 (HTTP only) and skips certificate handling. The WEBSITE_BUNDLE_CRT and WEBSITE_KEY variables are not required.
EXTERNAL_TLS_TERMINATION is case-sensitive. Accepted values: true, TRUE, 1, yes. Mixed-case values like True or Yes are not recognized and will silently fall back to in-pod TLS mode.
Backend Port Security
The backend must only be reachable from within the cluster. Do not expose it via LoadBalancer, NodePort, or a host-port mapping.
The backend trusts the X-Forwarded-For header set by the UI's Nginx reverse proxy to identify client IP addresses for:
- Per-IP login lockout — rate-limits failed login attempts by source IP
- Audit logging — records the client's real IP in audit events
If an attacker can reach the backend directly (bypassing the UI), they can inject arbitrary X-Forwarded-For values. This defeats both the lockout mechanism and the integrity of audit logs.
In Docker Compose, do not publish the backend port (no ports: mapping). In Kubernetes, keep the backend Service as ClusterIP.
Client IP Preservation
StorageLink uses the client's real IP address for login lockout and audit logging. How the client IP reaches the backend depends on your network architecture.
Direct LoadBalancer (Default)
When the UI Service is type LoadBalancer with externalTrafficPolicy: Local, the cloud load balancer preserves the client's source IP. The UI's Nginx proxy then passes it to the backend via X-Forwarded-For.
spec:
type: LoadBalancer
externalTrafficPolicy: Local
Trade-off: Local only routes to nodes that have a UI pod. If a node has no UI pod, the load balancer skips it — this is why maxSurge: 1 and maxUnavailable: 0 are set on the UI Deployment, ensuring capacity during rolling updates.
Alternative: externalTrafficPolicy: Cluster distributes traffic across all nodes but SNATs (replaces) the source IP. Use this only if you do not need per-IP lockout or accurate audit source IPs.
Behind an Ingress Controller or L7 Load Balancer
When traffic passes through an ingress controller (e.g., NGINX Ingress, ALB Ingress, or a cloud L7 load balancer) before reaching the UI pods, the ingress layer must:
- Forward over HTTPS — The UI expects HTTPS on port 8443 by default. If you set
EXTERNAL_TLS_TERMINATION=true, the UI listens on port 8080 (HTTP) instead. - Preserve the
Hostheader — StorageLink uses the Host header for generating download and preview links. If the ingress rewrites the Host header, links will be broken. - Set
X-Forwarded-For— Most ingress controllers do this by default.
With EXTERNAL_TLS_TERMINATION=true, set the UI service to ClusterIP and configure the Ingress resource to handle TLS:
# UI Service (ClusterIP, no externalTrafficPolicy needed)
spec:
type: ClusterIP
ports:
- port: 8080
targetPort: 8080
externalTrafficPolicy only works when the Service is the actual entry point for external traffic. When an ingress controller is in the path, the ingress controller's own Service determines IP preservation — not the UI Service. Configure the ingress controller's externalTrafficPolicy instead.
PROXY Protocol
Some load balancers (e.g., AWS NLB) support PROXY protocol as an alternative to externalTrafficPolicy: Local. PROXY protocol encodes the client IP in a header at the TCP level. If your load balancer uses PROXY protocol, configure the ingress controller to decode it — the UI's Nginx does not natively support PROXY protocol.
Non-Default Ports
If the UI is served on a non-standard port (anything other than 443), StorageLink may generate download and preview links that include the port number. Ensure your ingress or load balancer passes the correct Host header (including the port) so that generated URLs match the user's browser address.
Trusted Proxy Configuration
By default, the backend identifies the client IP from X-Forwarded-For using Tomcat's built-in trusted proxy set (RFC 1918 private ranges + loopback). This works with no configuration when the UI is the only hop in front of the backend.
When an L7 load balancer or ingress controller sits in front of the UI, the recorded client IP collapses to the load balancer's address. The application still works — nothing errors — but the audit trail loses per-client attribution and the login lockout applies to the load balancer's IP rather than individual clients.
To restore per-client attribution, register your edge's address ranges:
| Deployment | Setting |
|---|---|
| Container (env var) | SECURITY_CLIENTIP_TRUSTEDPROXIES |
| VM (application.properties) | LOAD_BALANCER_ADDRESSES |
Use CIDR notation (bare hostnames are not supported):
- Azure Application Gateway: Use the Application Gateway subnet CIDR (e.g.,
10.224.1.0/24). Its instances draw addresses from that subnet and the CIDR is stable. - AWS ALB: Use the ALB subnet CIDRs. The ALB itself is a DNS name with rotating addresses, but the subnets you created it in are stable.
- Cloudflare: Use Cloudflare's published IP ranges, which rotate. Authenticated Origin Pulls (mTLS from Cloudflare to your origin) is an alternative that needs no address list.
This setting is opt-in, not required. A deployment behind an L7 load balancer that doesn't set it still works — it only costs audit-trail precision.
HTTPS Forwarding to Origin
When using an L7 load balancer that terminates TLS, the load balancer must forward traffic to the UI over HTTPS on port 443 (the default) or over HTTP on port 8080 with EXTERNAL_TLS_TERMINATION=true.
Do not forward plain HTTP to the UI's default HTTPS port (8443) — Nginx returns a 400 Bad Request for plain HTTP on an SSL listener. Conversely, the VM's port-80 server block has no location for API paths, so forwarding HTTP to port 80 returns 404 with an HTML body. Both failure modes are silent from the load balancer's perspective.
The common L7 pattern of "terminate TLS at the balancer, forward HTTP to the origin" requires setting EXTERNAL_TLS_TERMINATION=true on the UI container. Without it, you must accept double TLS termination (load balancer → HTTPS → UI).
Download and Preview URL Generation
StorageLink generates absolute URLs for file downloads and previews using X-Forwarded-Proto and X-Forwarded-Port headers. If your front proxy does not set these headers, or sets them incorrectly, download and preview links may point to the wrong protocol or port.
Ensure your ingress controller or load balancer sets:
X-Forwarded-Proto—httpsfor TLS-terminated connectionsX-Forwarded-Host— the originalHostheader value (most ingress controllers do this by default)
External TLS Pass-Through Mode
When deploying behind an L7 load balancer with EXTERNAL_TLS_TERMINATION=true, the UI's Nginx template can be configured to pass the client's X-Forwarded-For chain through to the backend instead of overwriting it. This allows the backend to see the original client IP as set by the load balancer.
This mode is only safe when the UI pod is reachable exclusively through the load balancer. If anything else in the cluster can reach the UI Service directly, it can inject a forged X-Forwarded-For chain. To enforce this:
- Set the UI Service to
ClusterIP(notLoadBalancer) - Apply a
NetworkPolicyrestricting ingress to the UI pods to the ingress controller or Application Gateway subnet
Security Context (Kubernetes)
Both images are built for non-root execution and are compatible with Kubernetes restricted Pod Security Standards.
Backend
The backend runs as UID 100 (swiftgw) with a read-only root filesystem. It requires emptyDir mounts for writable paths:
securityContext:
runAsUser: 100
runAsNonRoot: true
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
Required writable mounts:
| Mount Path | Purpose |
|---|---|
/tmp | Temporary files |
The backend uses the json-logs profile which writes structured JSON to stdout — no file-based log directories are needed. The backend is stateless: all persistent state is stored in PostgreSQL, so no PersistentVolumeClaim is required. This enables horizontal scaling with multiple replicas.
UI
The UI runs as UID 1000 (the image's default user). Its entrypoint writes runtime configuration files (webconfig.js, nginx conf, TLS certs) at startup, so readOnlyRootFilesystem must be false:
securityContext:
runAsUser: 1000
runAsNonRoot: true
allowPrivilegeEscalation: false
readOnlyRootFilesystem: false
capabilities:
drop: ["ALL"]
The only required emptyDir mount is /tmp. The image's /var/run, /var/cache/nginx, /etc/nginx/templates, /etc/nginx/conf.d, and /var/run/swiftgw-webconfig directories are writable by UID 1000 in the image — do not mount emptyDir volumes over them, as this would erase the image's directory structure and cause startup failures.
Pod Security Context
Both pods should set:
securityContext:
runAsNonRoot: true
seccompProfile:
type: RuntimeDefault
Local File Storage (LFS) Mode
StorageLink can store files on a local filesystem instead of cloud storage. This is useful for air-gapped environments or when cloud storage is not required.
Configure the backend with:
FEATURES_FIRST_CONNECTION_CLOUD_PROVIDER: lfs
FEATURES_FIRST_CONNECTION_NAME: "Local Storage"
FEATURES_FIRST_CONNECTION_BASE_PREFIX: /mnt/data
Unlike cloud storage mode (where the backend is fully stateless), LFS mode stores files on the local filesystem and requires a PersistentVolumeClaim. When using LFS with multiple replicas, the volume must support ReadWriteMany (e.g., Azure Files, EFS, or NFS) so all backend pods can access the same files.
Mount Path Rules
StorageLink restricts which directories can be used as local storage mount paths. These rules prevent web administrators from accidentally exposing application configuration files (such as database credentials) or audit logs through the file browser.
Absolute paths must be entirely outside the application home directory (/opt/swiftgw). The following are all rejected:
| Path | Why it's rejected |
|---|---|
/opt/swiftgw | The application home itself |
/opt/swiftgw/data | A subdirectory inside the application home |
/opt/swiftgw/log | Would expose audit and application logs |
/opt | An ancestor directory that contains the application home |
These checks resolve symbolic links before comparing paths, so creating a symlink to bypass the restriction will not work.
Valid absolute paths are any directory outside the application home:
| Path | Notes |
|---|---|
/mnt/data | Recommended for container deployments — mount a PVC here |
/data | Any top-level directory outside /opt/swiftgw works |
/var/storagelink/files | Arbitrary path, as long as it's outside the app home |
Relative paths (e.g., uploads, data) are treated as managed mount names. They resolve inside a reserved directory at /opt/swiftgw/mounts/. For example, uploads becomes /opt/swiftgw/mounts/uploads. This is a safe exception to the "outside app home" rule — the mounts/ subdirectory is specifically designed for this purpose and does not contain application secrets. Managed mount rules:
uploads— valid, resolves to/opt/swiftgw/mounts/uploadsprod/uploads— valid, subdirectories are allowed.— rejected, cannot be the mounts directory itself../escaped— rejected, cannot escape the mounts directory
For container deployments, absolute paths outside the app home (like /mnt/data) are the simplest approach — just mount a PVC at that path. Managed mounts are more useful for VM deployments where you want StorageLink to manage the directory structure.
PostgreSQL Requirements
StorageLink requires PostgreSQL 16 or later.
Required extension: The ltree extension is used by StorageLink's database schema. On self-managed PostgreSQL, this is typically available by default. On managed services, you may need to allowlist it explicitly:
- Azure Database for PostgreSQL:
az postgres flexible-server parameter set \
--server-name <server> --resource-group <rg> \
--name azure.extensions --value LTREE - AWS RDS / Aurora: The
ltreeextension is available by default. - Google Cloud SQL: The
ltreeextension is available by default.
Docker Compose Quickstart
1. Generate Credentials
SECURITY_CLIENT_ID=$(openssl rand -hex 16)
SECURITY_CLIENT_SECRET=$(openssl rand -hex 16)
SECURITY_JWT_SECRET=$(openssl rand -base64 32)
# Generate a self-signed TLS certificate
openssl req -x509 -newkey rsa:2048 -keyout tls.key -out tls.crt \
-days 365 -nodes -subj "/CN=storagelink" 2>/dev/null
# Save to .env (Docker Compose reads this automatically)
{
echo "SECURITY_CLIENT_ID=${SECURITY_CLIENT_ID}"
echo "SECURITY_CLIENT_SECRET=${SECURITY_CLIENT_SECRET}"
echo "SECURITY_JWT_SECRET=${SECURITY_JWT_SECRET}"
printf 'WEBSITE_BUNDLE_CRT="%s"\n' "$(cat tls.crt)"
printf 'WEBSITE_KEY="%s"\n' "$(cat tls.key)"
} > .env
rm -f tls.crt tls.key
echo "Credentials saved to .env"
2. Create docker-compose.yml
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: swift_gw
POSTGRES_USER: swiftgw
POSTGRES_PASSWORD: swiftgw
PGDATA: /var/lib/postgresql/data/pgdata
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U swiftgw -d swift_gw"]
interval: 5s
timeout: 5s
retries: 5
restart: unless-stopped
networks:
- storagelink
backend:
image: <backend-image> # replace with your image reference
depends_on:
db:
condition: service_healthy
environment:
SPRING_DATASOURCE_URL: jdbc:postgresql://db:5432/swift_gw
SPRING_DATASOURCE_USERNAME: swiftgw
SPRING_DATASOURCE_PASSWORD: swiftgw
SPRING_PROFILES_ACTIVE: container,json-logs
SECURITY_CLIENT_ID: ${SECURITY_CLIENT_ID}
SECURITY_CLIENT_SECRET: ${SECURITY_CLIENT_SECRET}
SECURITY_JWT_SECRET: ${SECURITY_JWT_SECRET}
SERVER_PORT: "8080"
FEATURES_SYSTEMD_NOTIFY: "false"
FEATURES_SFTP_SUBSYSTEM_AUTO_START: "false"
LICENSE: trial
# Do not publish the backend port. Direct access allows X-Forwarded-For
# spoofing, which defeats per-IP login lockout and forges audit source IPs.
# The UI reaches the backend over the internal network via BACKEND_URL.
user: "100"
restart: unless-stopped
networks:
- storagelink
ui:
image: <ui-image> # replace with your image reference
depends_on:
- backend
environment:
BACKEND_URL: http://backend:8080/
SECURITY_CLIENT_ID: ${SECURITY_CLIENT_ID}
SECURITY_CLIENT_SECRET: ${SECURITY_CLIENT_SECRET}
CLOUD_PROVIDER: azure
WEBSITE_BUNDLE_CRT: ${WEBSITE_BUNDLE_CRT}
WEBSITE_KEY: ${WEBSITE_KEY}
ports:
- "443:8443"
user: "1000"
restart: unless-stopped
networks:
- storagelink
volumes:
postgres_data:
networks:
storagelink:
driver: bridge
3. Start the Services
docker compose up -d
Access the admin dashboard at https://localhost. Create your admin account (see Initial Admin Account) and configure your cloud storage connection.
Kubernetes Deployment
The following example deploys StorageLink as separate backend and UI pods with an external PostgreSQL database.
For a production-ready deployment with configurable values, see the cloud-specific Helm chart guides linked at the top of this page.
Prerequisites
- A Kubernetes cluster (AKS, EKS, GKE, or self-managed)
- An external PostgreSQL database (or use the Helm chart's built-in PostgreSQL subchart)
kubectlconfigured for your cluster
Create the Namespace and Secrets
kubectl create namespace storagelink
# Database credentials
kubectl create secret generic storagelink-db-secret \
--namespace storagelink \
--from-literal=POSTGRES_USER=swiftgw \
--from-literal=POSTGRES_PASSWORD='<your-db-password>' \
--from-literal=POSTGRES_DB=swift_gw
# Application secrets
CLIENT_ID=$(openssl rand -hex 16)
CLIENT_SECRET=$(openssl rand -hex 16)
JWT_SECRET=$(openssl rand -base64 32)
kubectl create secret generic storagelink-backend-secret \
--namespace storagelink \
--from-literal=SPRING_DATASOURCE_USERNAME=swiftgw \
--from-literal=SPRING_DATASOURCE_PASSWORD='<your-db-password>' \
--from-literal=SECURITY_CLIENT_ID="$CLIENT_ID" \
--from-literal=SECURITY_CLIENT_SECRET="$CLIENT_SECRET" \
--from-literal=SECURITY_JWT_SECRET="$JWT_SECRET" \
--from-literal=LICENSE='<your-license-key>'
# Generate self-signed cert for testing
openssl req -x509 -newkey rsa:2048 -keyout tls.key -out tls.crt \
-days 365 -nodes -subj "/CN=storagelink" 2>/dev/null
kubectl create secret generic storagelink-ui-secret \
--namespace storagelink \
--from-literal=SECURITY_CLIENT_ID="$CLIENT_ID" \
--from-literal=SECURITY_CLIENT_SECRET="$CLIENT_SECRET" \
--from-file=WEBSITE_BUNDLE_CRT=tls.crt \
--from-file=WEBSITE_KEY=tls.key
rm -f tls.crt tls.key
Backend Deployment
apiVersion: v1
kind: ConfigMap
metadata:
name: storagelink-backend-config
namespace: storagelink
data:
SPRING_PROFILES_ACTIVE: container,json-logs
LOGGING_LEVEL_ROOT: INFO
SERVER_PORT: "8080"
DB_HOST: "<your-pg-host>"
SPRING_DATASOURCE_URL: "jdbc:postgresql://<your-pg-host>:5432/swift_gw?sslmode=require"
FEATURES_INSTANCE_CLOUD_PROVIDER: azure
FEATURES_SYSTEMD_NOTIFY: "false"
FEATURES_SFTP_SUBSYSTEM_AUTO_START: "false"
---
apiVersion: v1
kind: Service
metadata:
name: backend
namespace: storagelink
spec:
# WARNING: Do not change to LoadBalancer or NodePort. Direct access to the
# backend port allows an attacker to spoof X-Forwarded-For, defeating the
# per-IP login lockout and forging audit source IPs.
type: ClusterIP
selector:
app: backend
ports:
- name: http
port: 8080
targetPort: 8080
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: backend
namespace: storagelink
spec:
replicas: 2
selector:
matchLabels:
app: backend
template:
metadata:
labels:
app: backend
spec:
securityContext:
runAsNonRoot: true
seccompProfile:
type: RuntimeDefault
affinity:
podAntiAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
podAffinityTerm:
labelSelector:
matchLabels:
app: backend
topologyKey: kubernetes.io/hostname
containers:
- name: backend
image: <backend-image> # replace with your image reference
imagePullPolicy: Always
securityContext:
runAsUser: 100
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: {}
UI Deployment
apiVersion: v1
kind: ConfigMap
metadata:
name: storagelink-ui-config
namespace: storagelink
data:
BACKEND_URL: "http://backend:8080/"
CLOUD_PROVIDER: azure
---
apiVersion: v1
kind: Service
metadata:
name: ui
namespace: storagelink
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: apps/v1
kind: Deployment
metadata:
name: ui
namespace: storagelink
spec:
replicas: 2
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
selector:
matchLabels:
app: ui
template:
metadata:
labels:
app: ui
spec:
securityContext:
runAsNonRoot: true
seccompProfile:
type: RuntimeDefault
affinity:
podAntiAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
podAffinityTerm:
labelSelector:
matchLabels:
app: ui
topologyKey: kubernetes.io/hostname
containers:
- name: ui
image: <ui-image> # replace with your image reference
imagePullPolicy: Always
securityContext:
runAsUser: 1000
runAsNonRoot: true
allowPrivilegeEscalation: false
readOnlyRootFilesystem: false
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: {}
Verify the Deployment
# Watch pods come up
kubectl get pods -n storagelink -w
# Get the UI external IP (may take a few minutes)
kubectl get svc ui -n storagelink
# Check backend logs
kubectl logs -n storagelink -l app=backend -f
The backend may take 1–2 minutes to start on first launch due to database migrations.
Open https://<EXTERNAL-IP> in your browser to access the StorageLink admin dashboard. You will see a certificate warning if using a self-signed certificate — this is expected. If you provided admin.username and admin.password in the Helm values, log in with those credentials. Otherwise, create your admin account on first access (see Initial Admin Account).
Troubleshooting
Backend CrashLoopBackOff
Check the backend logs:
kubectl logs -n storagelink -l app=backend --previous --tail=50
Common causes:
ltreeextension not available — See PostgreSQL Requirements.- Wrong database credentials — Verify secrets match the PostgreSQL server.
- Database not reachable — Check network connectivity and firewall rules.
Backend Stuck on "Waiting for changelog lock"
This happens when a previous backend instance crashed during database migrations, leaving a stale Liquibase lock:
UPDATE databasechangeloglock
SET locked = false, lockgranted = null, lockedby = null
WHERE id = 1;
The Helm chart includes an init container that clears this automatically on every pod start.
UI Shows "Bad Gateway" or Cannot Reach Backend
Verify the backend pod is running and the BACKEND_URL is correct:
kubectl get pods -n storagelink -l app=backend
kubectl get endpoints backend -n storagelink
TLS Certificate Not Working
- Check that both
WEBSITE_BUNDLE_CRTandWEBSITE_KEYare set. Providing only one causes the container to exit. - Ensure the values are PEM-encoded and not base64-wrapped (the container expects raw PEM).
- For certificate chains, the server certificate must come first.
Verifying Image Integrity
StorageLink container images include supply chain attestations — an SBOM (Software Bill of Materials) and build provenance — embedded at build time. These allow you to verify image contents and build origin before deploying.
Inspect Attestations
Use docker buildx imagetools to view the attestations attached to an image:
docker buildx imagetools inspect <backend-image>:<tag>
The output includes manifest entries for:
- SBOM (
application/spdx+json) — lists all packages and dependencies in the image - Provenance (
application/vnd.in-toto+json) — records the build environment, source commit, and build parameters
Extract SBOM
To extract the full SBOM as a JSON file:
# View SBOM attestation
docker buildx imagetools inspect <backend-image>:<tag> --format '{{ json .SBOM }}' > sbom.json
# List all packages
cat sbom.json | jq '.SPDX.packages[].name'
Extract Provenance
To view the build provenance (source commit, builder identity, build parameters):
docker buildx imagetools inspect <backend-image>:<tag> --format '{{ json .Provenance }}' > provenance.json
Verification in CI/CD
You can gate deployments on attestation verification. Example using docker buildx imagetools inspect in a pipeline:
# Fail if no attestations are present
ATTESTATIONS=$(docker buildx imagetools inspect <backend-image>:<tag> --format '{{ json .Manifest }}' \
| jq '[.manifests[] | select(.annotations["vnd.docker.reference.type"] == "attestation-manifest")] | length')
if [ "$ATTESTATIONS" -lt 1 ]; then
echo "ERROR: No attestations found on image"
exit 1
fi
echo "Image has $ATTESTATIONS attestation(s)"
Troubleshooting
"no attestation manifests found":
- Pre-release or development builds may not include attestations
- Ensure you are inspecting the multi-arch manifest (not a platform-specific digest)
Cannot pull attestation layers:
- Attestations are stored as OCI referrers alongside the image — ensure your registry supports OCI referrers (Docker Hub, ECR, and most modern registries do)
