Repository navigation
Deploying a Range
You deploy the export from your own machine, under your own credentials. redStackPRO is not in the loop once you press Download.
Caution
Deploying creates real, billed resources in your own cloud account: VMs, storage, a public IP or two, and the rest. Every shipped blueprint stops its instances 8 hours after the first apply, then daily at that time, but stopped disks and reserved addresses still bill, and a topology you start from an empty canvas has no auto-stop until you set one in Topology settings. Tear the range down when you are done (see Managing a range: status, start, stop, teardown below), and check your cloud console if a build ever fails partway through.
AWS only. On GCP, confirm a billing-enabled project and your CPUS_ALL_REGIONS
quota first (see Providers), then continue at Make an SSH key. On Azure,
the deploy checks VM sizes, vCPU and public IP quota, and resource providers itself
before it applies, so register Microsoft.DevTestLab if you have not (see
Cloud Prerequisites) and continue at Make an SSH key.
On AWS, scope your quotas for the target region before the first apply. A compile does not check them, and a shortfall surfaces mid-apply, after instances are up, leaving partial infrastructure to destroy. Each item is per region, so a new region means re-checking.
- Elastic IPs: the default is 5 per region. A defense/AD range uses about 2, an offense range about 3, so the default holds only one or two concurrent ranges. Request an increase in advance.
- vCPUs: a large AD range (for example full GOAD plus a SIEM) can exceed the default running On-Demand vCPU limit. Raise it before large deploys.
- Kali: a Kali operator needs a one-time Marketplace subscription on the account. It covers every region. redStackPRO touches no account, so you subscribe once yourself.
- Concurrent ranges: give each a distinct prefix so its host names stay distinguishable. The names that must be unique in the account or region (the key pair and the auto-stop IAM role and schedule) carry an automatic per-deployment suffix, so ranges in one account never collide, in the same region or across regions.
See Providers for the quota table and the increase commands.
Extract the download to its own folder, for example C:\redstackpro\my-range on
Windows or ~/redstackpro/my-range on macOS and Linux, and open PowerShell or a
terminal in it. Every step on this page runs from that folder: keys/ below is
relative to it, and manage.sh runs from it after the deploy, so keep it. On
Windows the path must not contain an apostrophe, because deploy.ps1 cannot run
from one.
An SSH key pair is two matching files: a private half you keep and a public half you can hand out. The deploy authenticates to the hosts with the private half, and the public half is written into each host at creation, so make the pair before the first apply.
Run this from the export's root folder, the one holding deploy.sh and keys/
(the command below creates keys/id_ed25519 and keys/id_ed25519.pub there):
macOS, Linux, or Git Bash on Windows:
ssh-keygen -t ed25519 -f keys/id_ed25519 -N ""
Windows PowerShell (5.1 or 7). Leave -N off and press Enter twice when it asks
for a passphrase. The deploy needs a key with no passphrase, and Windows PowerShell
5.1 drops an empty -N '', so ssh-keygen stops without writing a key:
ssh-keygen -t ed25519 -f keys\id_ed25519
keys/ sits beside DEPLOYMENT-GUIDE.md at the export root. Already have a key? Point at it with
REDSTACKPRO_SSH_KEY=/path/to/key bash deploy.sh, or in PowerShell with
$env:REDSTACKPRO_SSH_KEY = 'C:/path/to/key'; .\deploy.ps1.
On Windows, a key copied into keys\ takes that folder's permissions, and Windows
ssh refuses a private key other accounts can read: it prints
UNPROTECTED PRIVATE KEY FILE! and asks for a password. deploy.ps1 sets such a
key to you, SYSTEM, and Administrators only and prints a note saying so. A key
ssh-keygen made in place already has those permissions.
deploy.tfvars is a plain text file, one setting per line as name = "value"
(or name = ["value"] for a list). It lives at the export root, next to
deploy.sh. Open deploy.tfvars and set:
-
ssh_public_key: leave it empty anddeploy.shfills it from the key inkeys/, or paste the single line fromkeys/id_ed25519.pub. -
operator_source_ranges: the addresses you connect from, each as a/32. A CIDR like203.0.113.7/32means exactly that one address; find yours withcurl https://checkip.amazonaws.comor by searching "what is my ip".[!CAUTION] This variable gates management access to the jumpbox (SSH, and the Guacamole portal or the VPN port) next to a deliberately vulnerable range. RDP is not opened to it: the portal reaches the Windows hosts from the jumpbox. The export ships it commented out, and
deploy.shstops until you set it, suggesting your current public IP when it can look it up. A literal0.0.0.0/0, meaning anyone on the internet, is refused too, and the refusal names the override if you really mean it. Set it to your own/32(or your team's known ranges). -
region(AWS),project,region, andzone(GCP; the zone has to be inside the region), orsubscription_idandlocation(Azure), plus any topology-specific inputs the briefing names. A region (Azure calls it a location) is the cloud's data center location (for exampleus-east-1on AWS,us-east4on GCP,eastus2on Azure); pick one close to you or your target. A GCP project is the billing and resource container every GCP resource lives in; if you do not have one yet, see Providers for how to create one and enable billing. -
Azure only:
resource_group, the resource group the range is created in and that holds all of it (default{prefix}-{topology name}-rg). The deploy creates it and teardown deletes it, so it must not already exist; the preflight stops if it does. Optionallyvm_sizes, the VM size per role.deploy.tfvarslists this topology's roles with their default sizes, commented out; uncomment it and keep only the roles you change. See Cloud Prerequisites.
deploy.tfvars explains each value and where to find it, in a comment above it.
deploy.sh stops early, before anything is created, on an empty GCP project, an
empty Azure subscription_id, or a zone outside its region.
You edit deploy.tfvars only. deploy.sh copies it into
terraform/terraform.tfvars at apply time (and aborts if deploy.tfvars is
missing), so terraform/terraform.tfvars is managed for you: do not edit it by
hand.
Authenticate to your cloud first: aws configure, then aws sts get-caller-identity,
or gcloud auth login then gcloud auth application-default login, or az login
(or a service principal in ARM_CLIENT_ID, ARM_CLIENT_SECRET, ARM_TENANT_ID, and
ARM_SUBSCRIPTION_ID). That needs your cloud's CLI installed; on Windows, winget
installs it. For the install commands, the full auth steps, and the permissions your
identity needs, see Cloud Prerequisites.
An attack-infrastructure jumpbox names its team directly. Two overlay fields control it, both optional:
-
operators: a list of{handle, role}entries, one per teammate. Each handle is unique and lowercase. Every operator gets a Guacamole portal login (the handle as the username, on the shared lab password), regardless of access mode. -
access_mode:public(default),wireguard, oropenvpn.vpn_portdefaults to 51820 for WireGuard or 1194 for OpenVPN, andvpn_protocolisudportcp(WireGuard is udp only; OpenVPN can run tcp/443 to get through a restrictive network).
With a VPN access mode, provisioning on the jumpbox also creates a personal
client config for each operator, a WireGuard .conf or an OpenVPN .ovpn. The
keys are generated on the jumpbox and never enter the export. Each client config
holds that operator's private key, so treat it as a credential: fetch it once
provisioning has finished (RUN EXITED ok=1) and hand it only to that operator.
Each lands at /opt/redstackpro/vpn/<handle>.conf (or .ovpn), owned by the
platform account, so you fetch it yourself over your own ssh session:
scp -i keys/id_ed25519 redop@<jumpbox-ip>:/opt/redstackpro/vpn/<handle>.conf .
Firewall posture changes with the access mode:
-
public(default): both ssh (22) and the Guacamole portal (443) stay open tooperator_source_ranges. -
wireguardoropenvpn: ssh (22) stays open tooperator_source_rangesso the admin can keep deploying and managing the box, the public portal (443) closes to the internet and is reached instead over the tunnel at the jumpbox's private/tunnel address, and the VPN listen port opens tooperator_source_rangesin its place. The jumpbox keeps the same public IP either way; it is the VPN endpoint too.
OFFENSE-BRIEFING.md in the export gets an "Operators & VPN access" section
listing the endpoint (jumpbox public IP plus port and protocol), each
operator's portal account, and the path to their credential file, with the
scp line to fetch it.
With a wireguard or openvpn access mode, add or remove a teammate on a running
jumpbox without a redeploy:
sudo rsp-operator add <handle> [role]
sudo rsp-operator remove <handle>
sudo rsp-operator list
rsp-operator generates or revokes the VPN credential and the portal account
(an operator added this way gets a portal password of their own, which it
prints), and keeps a durable roster at /opt/redstackpro/vpn/operators.json, so
an operator added this way survives the next redeploy. A public jumpbox has no
rsp-operator: change operators on the canvas and deploy again.
A defensive range ignores all of this: operators, access_mode, and the rest
are attack-infrastructure-only fields, and a defense range keeps the plain
public portal.
The generated terraform/versions.tf has a terraform block (required_version and
required_providers) and the provider block, but no backend block, on GCP, AWS, and
Azure. That means Terraform keeps local state: a terraform.tfstate file inside the
export's own terraform/ folder, on your machine. There is no remote backend, so the
state and its lock live only on this machine and nothing coordinates two machines;
this is a single operator workflow. Do not run terraform apply against the same export from
two machines at once, and never commit terraform.tfstate (it can hold values that
should stay private).
If you deploy redStack (attack infrastructure) and a range as two separate exports,
each gets its own terraform/ folder and its own state file: two independent
deploys, and two independent teardowns. See Managing a range: status, start,
stop, teardown below.
Windows PowerShell, from the export folder:
.\deploy.ps1
macOS, Linux, or Git Bash:
bash deploy.sh
On Windows, do not run bash deploy.sh at a PowerShell prompt: that bash is the WSL
launcher. WSL (Windows Subsystem for Linux) runs a separate Linux environment with its
own filesystem, home directory, and credentials, so the deploy would run somewhere your
SSH key and cloud login are not. deploy.ps1 finds Git Bash and calls it correctly.
deploy.ps1 and manage.ps1 are unsigned, so PowerShell's execution policy has to
allow them.
deploy.sh applies the Terraform, then provisions from the range's own jumpbox. The
managed hosts sit on a private subnet nothing else can reach, so provisioning runs from
inside the range rather than from your laptop. Your cloud credentials stay on your
machine throughout.
On Azure, deploy.sh runs azure_preflight.py after terraform init and before
terraform apply. If a VM size is not available to your subscription, a vCPU or
public IP quota is short, or a resource provider is not registered, it stops before
anything is created and prints what to change. See The Azure preflight in
Cloud Prerequisites.
OFFENSE-BRIEFING.md (attack infrastructure) or DEFENSE-BRIEFING.md (a range) in
the export lists the credentials and what is planted where.
The run opens on the redStackPRO wordmark and a title box naming the range, the
provider and region, and the path of this run's log. Then it prints numbered phases,
[1/7] Terraform apply to [7/7] Launch provisioning on the jumpbox on GCP and AWS,
or eight on Azure with [1/8] Preflight: VM sizes, quota, providers first. Each
phase line carries the time it started, and each phase ends on an indented ok line
with a short result. A yellow note line reports something the deploy did for you,
such as filling an empty ssh_public_key from the key in keys/, or, from
deploy.ps1, setting a key in keys\ to you only (see Make an SSH key).

Terraform's output is condensed on screen: the plan line, then one line per
finished resource with its mark (+ created, ~ changed, - destroyed), how long
it took, its name, and its kind; an error block in full; and a warning's title. A
41-resource Azure apply prints 42 lines on screen (the plan line and one per
resource), where Terraform printed about 1120. Shortened:
[2/8] Terraform apply .............................. 09:51
plan 41 to add, 0 to change, 0 to destroy
+ 0s lab (password)
...
+ 1m3s def-kingslanding (windows virtual machine)
...
+ 2m4s def-kingslanding/redstackpro-winrm (virtual machine extension)
ok 41 resources in 4m44s
Everything terraform apply printed is still in the deploy log (see Deploy
logs, for troubleshooting below).
A run that gets through ends on a green DONE box:
==============================================================
DONE Infrastructure is up. Provisioning is running.
--------------------------------------------------------------
Portal https://<jumpbox>/guacamole
Logins, run:
terraform -chdir=terraform output guacamole
Watch provisioning, run (it returns when the run ends):
ssh -i keys/id_ed25519 blueop@<jumpbox> rsp-watch
Finished when run.log shows RUN EXITED ok=1 (45-75 min)
Log logs/deploy-<UTC>.log
==============================================================

The box gives the portal address, the command that prints the portal logins, the
ssh line that watches provisioning (see Following provisioning on the jumpbox
below), how to tell provisioning has finished, and the deploy log's path. The
example is a defense range. On attack infrastructure the account is redop and the
expected time is 60 to 75 minutes. Every command the deploy prints, in the box and in the reasons it
stops, sits alone on its own line under a label that says to run it, so a copied
line is the whole command. Run from deploy.ps1, the box writes Windows paths
(keys\id_ed25519) and the commands in PowerShell form, and the ssh line pastes
into PowerShell as is. If a redirector's DNS record does not point at it yet, the
box adds a DNS line naming the A record to set.
A run that stops ends on a red FAILED box instead:
==============================================================
FAILED [2/8] Terraform apply
--------------------------------------------------------------
Reason <why it stopped>
Exit <exit code>
Log logs/deploy-<UTC>.log (secrets scrubbed)
Report attach the log to a new issue:
https://github.com/devZero-Security/redStackPRO/issues/new/choose
==============================================================
It names the phase, the reason, the exit code, and the log, with the link to open an issue. When Terraform fails, its error block prints in full above the box.

Here the Azure preflight caught a resource group that already exists and stopped before apply, so nothing was created. The reason line tells you how to fix it.
On a terminal the output is in colour: the red wordmark, red rules around the
title box and the FAILED box, green rules around the DONE box, bold phase
titles with a cyan [n/N], green ok and DONE, red FAILED and failure
reasons, yellow notes and warnings, and cyan commands and portal address. Output
to a file or a pipe gets none, and the deploy log is always plain text, with no
colour codes and no wordmark. Set NO_COLOR to turn it off.
macOS, Linux, or Git Bash:
NO_COLOR=1 bash deploy.sh
Windows PowerShell:
$env:NO_COLOR = '1'; .\deploy.ps1
Provisioning runs on the jumpbox in a detached screen session and keeps going if
you disconnect, so the deploy ends while it is still running. Watch it with the ssh
line from the DONE box, which runs rsp-watch on the jumpbox. In this form it
pastes into PowerShell or bash alike (the account is redop on attack
infrastructure):
ssh -i keys/id_ed25519 blueop@<jumpbox> rsp-watch
rsp-watch shows ~/provision/run.log from the top and returns when the run ends:
exit 0 for RUN EXITED ok=1, 1 otherwise. Ctrl-C stops the watching, not the
build. If Windows ssh refuses the key with UNPROTECTED PRIVATE KEY FILE! and asks
for a password, see Make an SSH key above.
~/provision/run.log is the condensed view. It gives each stage a heading
(Jumpbox pass 1 of 3, Waiting for authenticated WinRM, then
site.yml attempt 1 of 8 on a defense range; site.yml attempt 1 of 8 on attack
infrastructure), leaves out hosts a task skipped, marks a failed task with !!, and
ends on a RUN EXITED line followed by the last PLAY RECAP line for every host.
RUN EXITED ok=1 means provisioning is done; ok=0 means the last attempt failed,
and the lines marked !! name the failed tasks. Every line Ansible printed, skips
included, is in ~/provision/run-full.log beside it.
The export ships verify.py next to deploy.sh. It checks a live range against the
export that built it and exits 0 (all passed), 1 (a check failed), or 2 (the checks
could not run). It needs PyYAML on your machine; without it Python stops on the
import before any check runs.
Windows (PowerShell or Git Bash):
python -m pip install pyyaml
macOS or Linux:
python3 -m pip install pyyaml
If pip refuses with externally-managed-environment (Debian 12, Ubuntu 23.04 and
later, Homebrew Python), install the distribution package instead
(sudo apt install python3-yaml on Debian and Ubuntu) or install PyYAML in a venv.
Then, from the export folder. Windows:
python verify.py doors . # offense only: redirector front doors, probed from outside
python verify.py stack . # the range over SSH from the jumpbox
macOS or Linux:
python3 verify.py doors . # offense only: redirector front doors, probed from outside
python3 verify.py stack . # the range over SSH from the jumpbox
doors exits 2 on a defense range, which has no redirector.
doors validates each redirector's TLS chain for its cert source, that every front
door serves its cover page, that decoys differ between doors, that the gating header
is enforced (an ungated request is turned away), and that one door's header value
does not open another. stack logs in over the jumpbox and checks that provisioning
finished, the Guacamole portal answers, the stock guacadmin account is gone, each
teamserver holds 443, and each redirector carries its certificate tool. Add --plan
to list the checks without running them.
On an offense range, sudo rsp-check on a redirector runs the same front-door checks
from the box itself, and OFFENSE-BRIEFING.md carries the C2 payload recipe (see
Redirectors and Cover Stories).
Every run of deploy.sh (and deploy.ps1, which calls it) writes one
timestamped log to logs/deploy-<UTC-timestamp>.log in the export. It holds every
phase and everything terraform apply printed, in full, including what the screen
condenses. It is plain text, with no colour codes, and opens with a header
(redStackPRO version, provider, Terraform version, your system) that goes to the
log only. Its last line is result: ok, or on a failure
result: FAILED (exit N) in phase ... with the phase the run stopped in, so a log
attached to an issue records how the run ended. When the run ends it is scrubbed of private keys
and any password, secret, or token assignment, so it is safe to attach to a
public issue.
Ansible runs on the jumpbox, detached, and keeps going after the deploy ends, so
its output is not in this log. The provisioning run log is ~/provision/run.log
on the jumpbox, with every line in ~/provision/run-full.log beside it, separate
from the deploy log on your machine (see Following provisioning on the
jumpbox above).
If a run fails, the FAILED box (see What the deploy prints above) prints the
log's path and a link to open an issue. The repo ships GitHub issue templates
(Deploy failure, Bug report) that ask for the log: if a deploy fails, or a range
comes up wrong, attach the newest logs/deploy-*.log to the issue.
If your topology has a redirector, set its domain before you compile: an export built without it must be recompiled, not patched after apply.
A redirector is the one host that answers to the internet by name. A domain has to be
registered and pointed at the box by a human, and nothing can invent one. Any shipped
example that carries a redirector arrives one field short on purpose, and the
validator says so with RDR001.
Supply a domain you control, either in the canvas inspector before you compile, or on the command line:
redstackpro compile <blueprint> --hostname <your domain> -o export
Use a domain you registered. A hostname containing example, change-me,
changeme, or your-domain is treated as a placeholder and still raises RDR001.
After apply, create the A record the briefing names, pointing your subdomain at the redirector's address. A DNS A record is the entry that maps a domain name to an IPv4 address; you create it in your domain registrar's or DNS provider's control panel (wherever you manage the domain), not anywhere in redStackPRO. See Redirectors and Cover Stories.
Every jumpbox export ships a lifecycle script, manage.sh (and manage.ps1 for
Windows PowerShell), with four subcommands. Run them from the export root, the
folder holding deploy.sh.
Ranges are meant to be stood up, used, and torn down, so nothing keeps costing
money after the work is done. Teardown runs the destroy for you: it copies
deploy.tfvars into terraform/ and then destroys.
macOS, Linux, or Git Bash:
bash manage.sh status
bash manage.sh stop
bash manage.sh start
bash manage.sh teardown
Windows PowerShell:
.\manage.ps1 status
.\manage.ps1 stop
.\manage.ps1 start
.\manage.ps1 teardown
Run it with no command (or help, -h, --help) for a help screen written for
this export: the range name, provider, and region, the resource group (Azure) or
GCP project read from deploy.tfvars, or on AWS the CLI profile from AWS_PROFILE
(default when unset), each command and what it does on this
provider (what still bills after stop, what teardown --force deletes first),
the auto-stop schedule, and the CLI and login it needs. An unknown command or
option prints the same screen and exits 1.
macOS, Linux, or Git Bash:
bash manage.sh
Windows PowerShell:
.\manage.ps1

status, start, and stop run through your cloud's CLI (gcloud, aws, or
az), which has to be installed and logged in; each checks for it first, and exits
nonzero when the call fails on any instance. Each opens on a title box like the
top of the help screen, without the wordmark: the command (STATUS, START, or
STOP), the range name, the provider and region, and the resource group, project,
or profile. They act on this range's own instances only, never the whole account
or project, and on AWS they use the region from deploy.tfvars.
-
status: one table for the range, each instance's state and public address, with the portal address in the box above it. -
stopandstart: the cost lever between runs.stophalts the instances so they stop billing for compute (GCP, AWS, and Azure all support this live), andstartbrings them back without a redeploy; provisioning is not re-run. Disks and reserved public addresses still bill while stopped, and on AWS and Azure so does the NAT gateway; the help screen lists them for this export. On Azure,stopdeallocates the VMs, which is what halts compute billing there, and the jumpbox keeps its public IP. -
teardown: deletes the whole range withterraform destroy. It cannot be undone, so it asks first (see below). -
teardown --force: recovery for an interrupted deploy (see below).

Proxmox and ESXi exports, which are on the roadmap, offer teardown only.
teardown first shows a TEARDOWN box with what it is about to delete (the range,
provider, region, and resource group, project, or profile) and a red line saying it
deletes the whole range and cannot be undone, then asks you to type the range name,
or yes. Any other answer deletes nothing. --yes skips the question, in either
order with --force. manage.ps1 asks with Read-Host. manage.sh asks only on a
terminal: without one and without --yes it stops and deletes nothing, so a script
cannot hang on the question.
The destroy then prints as one step, terraform destroy, shaped like a deploy
phase without a number: the plan line, one red - line per deleted resource, and
ok with the count destroyed and how long it took, then a green DONE box
(The range is deleted.) naming the teardown log, or a red FAILED box.
Everything Terraform printed goes to logs/teardown-<UTC-timestamp>.log, and that
log ends on a result: ok or result: FAILED (exit N) line.

macOS, Linux, or Git Bash, without the question:
bash manage.sh teardown --yes
Windows PowerShell, without the question:
.\manage.ps1 teardown --yes
teardown --force is recovery for an interrupted deploy. If you stop deploy.sh
partway (Ctrl-C, a closed terminal), the cloud can hold instances Terraform created
but never recorded in its state. A plain teardown cannot see those and may fail
trying to destroy a network they still sit in; its FAILED box then gives the
--force command. --force clears the cloud side first, then runs the destroy:
- GCP and AWS: it deletes (GCP) or terminates (AWS) this range's instances only.
Every instance carries a
redstackpro_deploylabel (GCP) or tag (AWS), a random id Terraform keeps in this deployment's state. The sweep takes the instances in that state plus any carrying its id, which still finds one an interrupted apply created but never recorded. Other ranges in the same project or region are not touched, and with no state it deletes no instance. It needsgcloudoraws. The sweep covers instances only, so after a forced teardown on GCP or AWS check the console for a leftover static or Elastic IP or NAT gateway. - Azure: it deletes the range's whole resource group with
az group delete, then runs the destroy. Withoutazit says so and runs the plain destroy.
macOS, Linux, or Git Bash:
bash manage.sh teardown --force
Windows PowerShell:
.\manage.ps1 teardown --force
After a clean teardown, re-running deploy.sh starts fresh.
If you deployed redStack (attack infrastructure) and a range as separate exports,
each has its own terraform/ state in its own folder and needs its own teardown:
run manage.sh teardown (or .\manage.ps1 teardown) once per export.
Each export is independent. Its Terraform state is local to that export's own
terraform/ folder, and there is no central fleet view across exports: the canvas
tracks nothing after Download, and one export knows nothing about another. The
recommended pattern is one folder per range, each with its own deploy.tfvars and
its own state, and you run manage.sh status (or .\manage.ps1 status) per range
from inside its folder. Teardown is likewise per folder.
Give each range a distinct prefix (the field next to the topology name on the
canvas) so its host names stay clear across your folders. On GCP two ranges in one
project need it: every resource name is built from the prefix and a node id (the
auto-stop policy is <prefix>-auto-stop), with no per-deployment suffix, and
network and firewall rule names are unique across the whole project, so a second
range with the same prefix stops at apply on a name already in use, in any region.
The shipped blueprints all start on def (defense) or off (offense), so change
the prefix before the second deploy. On AWS the names that must be unique in the
account or region (the key pair and the auto-stop IAM role and schedule) carry an
automatic per-deployment suffix, so ranges in one account never collide. On Azure
the resource group is the range. Its default name comes from the prefix and the
topology name, so two ranges with different names get different groups, and the
preflight stops an export whose group already exists outside its own state.
GCP, AWS, and Azure are tested end to end. Proxmox and ESXi are on the roadmap, not yet supported. See Providers for the full matrix and the setup notes for each.