Thorn Tech Marketing Ad
Skip to main content
Version: 1.1.6

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:

TypeNameValue
Astoragelink.yourdomain.com<alb_ip output>
info

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

  1. Wait at least 5 minutes after deployment — StorageLink requires time to fully initialize
  2. SSH into the instance and verify the service is running:
    sudo systemctl status swiftgateway
  3. Check application logs:
    sudo tail -f /opt/swiftgw/log/application-$(date +%Y-%m-%d).log
  4. Verify the instance can reach GCS (confirms Private Google Access is working):
    curl -v https://storage.googleapis.com
    A 400 MissingSecurityHeader response 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_ip output 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.

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

info

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_count without 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 the google_compute_security_policy resource 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.