Deploying StorageLink Behind an Application Load Balancer with Cloud Armor
Overview
This article walks through deploying StorageLink on Google Cloud Platform (GCP) using a Global External Application Load Balancer (ALB) with Cloud Armor (WAF) protection. In this configuration:
- The StorageLink instance has no public IP address — all inbound traffic flows through the load balancer
- Cloud Armor inspects every request before it reaches StorageLink, blocking XSS and SQL injection attacks
- SSH access is restricted to Google's Identity-Aware Proxy (IAP) — no direct internet SSH exposure
- Cloud NAT provides outbound internet access for the private instance
Architecture
Internet
│
▼
Cloud Armor (WAF — XSS, SQLi rules evaluated here)
│
▼
Global External ALB
├── Port 80 → HTTP-to-HTTPS redirect
└── Port 443 → HTTPS → StorageLink instance (port 443)
│
├── Private Google Access → GCS APIs
└── Cloud NAT → Outbound internet
Prerequisites
Before you begin, ensure you have the following:
- A GCP project with billing enabled
- Terraform >= 1.5.0 installed
- Google Cloud SDK (gcloud) installed and authenticated:
gcloud auth application-default login --scopes="openid,https://www.googleapis.com/auth/userinfo.email,https://www.googleapis.com/auth/cloud-platform" - A public domain name you control (required for the managed SSL certificate)
- The following GCP APIs enabled in your project:
- Compute Engine API
- Cloud Resource Manager API
Terraform Files
Create a new directory for your deployment and add the following five files.
versions.tf
terraform {
required_version = ">= 1.5.0"
required_providers {
google = {
source = "hashicorp/google"
version = ">= 5.25.0"
}
}
}
variables.tf
variable "project_id" {
type = string
default = "your-project-id"
}
variable "region" {
type = string
default = "us-east1"
}
variable "zones" {
type = list(string)
default = ["us-east1-b", "us-east1-c", "us-east1-d"]
}
variable "component" {
type = string
description = "Component name, e.g. 'storagelink'"
validation {
condition = can(regex("^[a-z][a-z0-9-]{1,30}$", var.component))
error_message = "component must be lowercase and may only contain letters, numbers, and hyphens. Example: storagelink"
}
}
variable "subnet_cidr" {
type = string
default = "10.20.0.0/24"
}
variable "machine_type" {
type = string
default = "e2-standard-2"
}
variable "instance_count" {
type = number
default = 1
}
variable "storagelink_image" {
type = string
description = "Marketplace image self-link to pin StorageLink version"
default = "https://www.googleapis.com/compute/v1/projects/thorn-technologies-public/global/images/storagelink-1-1-6-1764022736"
}
variable "domain_name" {
type = string
description = "Public DNS name for the managed SSL certificate, e.g. storagelink.example.com"
}
variable "backend_port" {
type = number
default = 443
}
variable "health_path" {
type = string
default = "/index.html"
}
main.tf
provider "google" {
project = var.project_id
region = var.region
}
locals {
name_root = var.component
}
# ---------------------------------------------------------------------------
# VPC + Subnet
# ---------------------------------------------------------------------------
resource "google_compute_network" "vpc" {
name = "${local.name_root}-vpc"
auto_create_subnetworks = false
}
resource "google_compute_subnetwork" "subnet" {
name = "${local.name_root}-subnet"
region = var.region
network = google_compute_network.vpc.id
ip_cidr_range = var.subnet_cidr
private_ip_google_access = true
}
# ---------------------------------------------------------------------------
# Cloud NAT
# ---------------------------------------------------------------------------
resource "google_compute_router" "router" {
name = "${local.name_root}-router"
region = var.region
network = google_compute_network.vpc.id
}
resource "google_compute_router_nat" "nat" {
name = "${local.name_root}-nat"
router = google_compute_router.router.name
region = var.region
nat_ip_allocate_option = "AUTO_ONLY"
source_subnetwork_ip_ranges_to_nat = "ALL_SUBNETWORKS_ALL_IP_RANGES"
min_ports_per_vm = 4098
}
# ---------------------------------------------------------------------------
# Firewall Rules
# ---------------------------------------------------------------------------
resource "google_compute_firewall" "allow_lb_hc" {
name = "${local.name_root}-fw-lb-hc"
network = google_compute_network.vpc.name
allow {
protocol = "tcp"
ports = ["80", "443"]
}
source_ranges = ["35.191.0.0/16", "130.211.0.0/22"]
target_tags = [local.name_root]
}
resource "google_compute_firewall" "allow_ssh_iap" {
name = "${local.name_root}-fw-ssh-iap"
network = google_compute_network.vpc.name
allow {
protocol = "tcp"
ports = ["22"]
}
source_ranges = ["35.235.240.0/20"]
target_tags = [local.name_root]
}
# ---------------------------------------------------------------------------
# Service Account + Instance Template
# ---------------------------------------------------------------------------
resource "google_service_account" "vm_sa" {
account_id = "${local.name_root}-sa"
display_name = "${local.name_root} VM service account"
}
resource "google_compute_instance_template" "tpl" {
name_prefix = "${local.name_root}-tpl-"
machine_type = var.machine_type
tags = [local.name_root]
disk {
boot = true
auto_delete = true
source_image = var.storagelink_image
}
network_interface {
subnetwork = google_compute_subnetwork.subnet.id
}
metadata = {
enable-oslogin = "TRUE"
block-project-ssh-keys = "TRUE"
}
service_account {
email = google_service_account.vm_sa.email
scopes = ["cloud-platform"]
}
}
# ---------------------------------------------------------------------------
# Health Check + Regional MIG
# ---------------------------------------------------------------------------
resource "google_compute_health_check" "hc" {
name = "${local.name_root}-hc"
https_health_check {
port = 443
request_path = var.health_path
}
timeout_sec = 5
check_interval_sec = 15
}
resource "google_compute_region_instance_group_manager" "mig" {
name = "${local.name_root}-mig"
region = var.region
base_instance_name = local.name_root
distribution_policy_zones = var.zones
version {
instance_template = google_compute_instance_template.tpl.id
}
target_size = var.instance_count
named_port {
name = "app"
port = var.backend_port
}
}
# ---------------------------------------------------------------------------
# Cloud Armor Security Policy
# ---------------------------------------------------------------------------
resource "google_compute_security_policy" "armor" {
name = "${local.name_root}-armor-policy"
description = "Cloud Armor WAF policy for StorageLink ALB"
# Allow all StorageLink API and auth traffic before WAF rules are evaluated.
# All /backend/ endpoints are protected by Spring Boot Bearer token authentication.
# WAF rules cause false positives on login credentials, JSON service account keys,
# password fields, and connection config data.
rule {
action = "allow"
priority = 500
match {
expr {
expression = "request.path.startsWith('/backend/')"
}
}
description = "Allow StorageLink backend API - WAF false positives on API request bodies"
}
rule {
action = "deny(403)"
priority = 1000
match {
expr {
expression = "evaluatePreconfiguredExpr('xss-stable')"
}
}
description = "Block XSS attacks"
}
rule {
action = "deny(403)"
priority = 1001
match {
expr {
expression = "evaluatePreconfiguredExpr('sqli-stable')"
}
}
description = "Block SQL injection"
}
rule {
action = "allow"
priority = 2147483647
match {
versioned_expr = "SRC_IPS_V1"
config {
src_ip_ranges = ["*"]
}
}
description = "Default allow"
}
}
# ---------------------------------------------------------------------------
# Load Balancer
# ---------------------------------------------------------------------------
resource "google_compute_global_address" "lb_ip" {
name = "${local.name_root}-lb-ip"
}
resource "google_compute_backend_service" "be" {
name = "${local.name_root}-be"
load_balancing_scheme = "EXTERNAL"
protocol = "HTTPS"
port_name = "app"
timeout_sec = 600
health_checks = [google_compute_health_check.hc.id]
security_policy = google_compute_security_policy.armor.id
backend {
group = google_compute_region_instance_group_manager.mig.instance_group
}
}
resource "google_compute_url_map" "urlmap" {
name = "${local.name_root}-urlmap"
default_service = google_compute_backend_service.be.id
}
resource "google_compute_managed_ssl_certificate" "cert" {
name = "${local.name_root}-cert"
managed {
domains = [var.domain_name]
}
}
resource "google_compute_target_https_proxy" "https_proxy" {
name = "${local.name_root}-https-proxy"
url_map = google_compute_url_map.urlmap.id
ssl_certificates = [google_compute_managed_ssl_certificate.cert.id]
}
resource "google_compute_global_forwarding_rule" "fr_https" {
name = "${local.name_root}-fr-https"
ip_address = google_compute_global_address.lb_ip.address
port_range = "443"
load_balancing_scheme = "EXTERNAL"
target = google_compute_target_https_proxy.https_proxy.id
}
resource "google_compute_url_map" "redirect_map" {
name = "${local.name_root}-redirect-map"
default_url_redirect {
https_redirect = true
strip_query = false
}
}
resource "google_compute_target_http_proxy" "http_proxy" {
name = "${local.name_root}-http-proxy"
url_map = google_compute_url_map.redirect_map.id
}
resource "google_compute_global_forwarding_rule" "fr_http" {
name = "${local.name_root}-fr-http"
ip_address = google_compute_global_address.lb_ip.address
port_range = "80"
load_balancing_scheme = "EXTERNAL"
target = google_compute_target_http_proxy.http_proxy.id
}
outputs.tf
output "alb_ip" {
value = google_compute_global_address.lb_ip.address
description = "Create a DNS A record pointing to this IP for the managed SSL certificate to provision"
}
output "alb_hostname" {
value = var.domain_name
}
terraform.tfvars
Create this file and fill in your values:
project_id = "your-project-id"
region = "us-east1"
zones = ["us-east1-b", "us-east1-c", "us-east1-d"]
component = "storagelink"
storagelink_image = "https://www.googleapis.com/compute/v1/projects/thorn-technologies-public/global/images/storagelink-1-1-6-1764022736"
machine_type = "e2-standard-2"
instance_count = 1
subnet_cidr = "10.20.0.0/24"
domain_name = "storagelink.yourdomain.com"
backend_port = 443
health_path = "/index.html"
Deployment Steps
Step 1 — Authenticate with GCP
gcloud auth application-default login --scopes="openid,https://www.googleapis.com/auth/userinfo.email,https://www.googleapis.com/auth/cloud-platform"
Step 2 — Initialize Terraform
From your deployment directory:
terraform init
Step 3 — Review the Plan
terraform plan
Review the output to confirm the resources that will be created before applying.
Step 4 — Deploy
terraform apply
Type yes when prompted. Deployment typically takes 3–5 minutes. Note the alb_ip value from the output — you will need it in the next step.
Step 5 — Create a DNS A Record
Point your domain name to the ALB IP from the output:
| Type | Name | Value |
|---|---|---|
| A | storagelink.yourdomain.com | <alb_ip output> |
The Google-managed SSL certificate will not provision until a valid DNS A record is in place. This can take up to 15 minutes after the DNS record propagates.
Verifying the Deployment
Check the instance is healthy
gcloud compute backend-services get-health storagelink-be \
--global \
--project=your-project-id
You should see healthState: HEALTHY. If the instance shows UNHEALTHY, wait a few minutes — StorageLink takes approximately 50 seconds to fully start.
Check the SSL certificate has provisioned
gcloud compute ssl-certificates describe storagelink-cert \
--global \
--project=your-project-id \
--format="get(managed.status,managed.domainStatus)"
Status should be ACTIVE. If it shows PROVISIONING, the DNS record may not have propagated yet.
Accessing the Admin Portal
Once the health check is healthy and the SSL certificate is active, navigate to:
https://storagelink.yourdomain.com
You will see a StorageLink landing page. Click "Click here to access your web admin portal" to proceed to the admin UI.
HTTP traffic to port 80 is automatically redirected to HTTPS.
SSH Access (via IAP)
The StorageLink instance has no public IP address. To SSH into it, use Google's Identity-Aware Proxy:
# Find the instance name
gcloud compute instances list --project=your-project-id
# SSH via IAP tunnel
gcloud compute ssh INSTANCE_NAME \
--tunnel-through-iap \
--zone=ZONE \
--project=your-project-id
Troubleshooting
Health check shows UNHEALTHY / 502 error on ALB
- Wait at least 5 minutes after deployment — StorageLink requires time to fully initialize
- SSH into the instance and verify the service is running:
sudo systemctl status swiftgateway - Check application logs:
sudo tail -f /opt/swiftgw/log/application-$(date +%Y-%m-%d).log - Verify the instance can reach GCS (confirms Private Google Access is working):A
curl -v https://storage.googleapis.com400 MissingSecurityHeaderresponse means connectivity is working correctly — the instance reached GCS but the request needs authentication.
SSL certificate stuck in PROVISIONING
- Confirm the DNS A record exists and points to the
alb_ipoutput value - DNS propagation can take up to 60 minutes depending on your DNS provider
- The certificate will not provision without a valid DNS record
Cannot reach the admin portal after SSL is active
Ensure you are using https:// — the admin portal is only accessible over HTTPS. HTTP requests are automatically redirected.
Login or account creation returns 403 Forbidden
Cloud Armor's WAF rules can trigger false positives on StorageLink API requests. The SQLi rule matches on special characters in passwords, JSON service account key content, and connection configuration data. The template includes a priority 500 allow rule for all /backend/ paths to prevent this. If you have modified the Cloud Armor policy, ensure this rule is present and has a lower priority number than the XSS and SQLi deny rules.
GCS cloud connection test fails with 403
Same cause as above — the JSON service account key in the request body triggers the SQLi rule. Ensure the /backend/ allow rule is in place at priority 500.
Errors in StorageLink logs: ClientAbortException / EOFException
These errors appear in /opt/swiftgw/log/application-*.log when the browser cancels in-flight requests during real-time input validation (e.g. typing in a password field). This is normal browser behavior and is not indicative of a problem with the deployment or application.
Important Notes
Configuration is stored locally on the instance. StorageLink uses an embedded local database — there is no external configuration backend in this deployment. If the instance is replaced for any reason (auto-healing, manual deletion, instance template update, or MIG recreation), all StorageLink configuration will be lost, including cloud connections, user accounts, and SFTP settings. You will need to reconfigure StorageLink from scratch on the new instance. Do not perform any action that replaces the instance without first exporting your configuration or taking a disk snapshot.
- Instance count: This template deploys a single StorageLink instance. Scaling to multiple instances requires an external database for configuration sync. Do not increase
instance_countwithout first setting up a shared configuration backend. - Auto-healing: Auto-healing is intentionally disabled. Because StorageLink uses a local database, automatic instance replacement would result in complete configuration loss. If the instance becomes unhealthy, investigate and recover it manually rather than allowing the MIG to replace it.
- Cloud Armor rules: The default policy blocks XSS and SQL injection attempts at the public layer while allowing all
/backend/API traffic through. Additional rules (IP allowlists, geo-blocking, rate limiting) can be added to thegoogle_compute_security_policyresource as needed. - GCS credentials: StorageLink connects to GCS using a JSON service account key uploaded through the admin UI. The VM's attached service account does not require GCS IAM roles.
- File transfers: Cloud Armor only inspects the first 8KB of any request body. Large file uploads are not blocked by size. Downloads (responses) are never inspected by Cloud Armor. The backend service timeout is set to 600 seconds (10 minutes) to accommodate large file uploads — the default of 30 seconds will cause large files to stall indefinitely in the upload queue.
