RedAmon single-host deploy
July 13, 2026 · View on GitHub
Provision and operate a full RedAmon instance on any Linux server (EC2, DigitalOcean, Hetzner, bare metal) from your laptop, given only its IP + an SSH credential + a domain.
What this is
deploy.sh is a thin remote driver around RedAmon's own redamon.sh control script. It
prepares a bare host, clones the repo onto it, drives redamon.sh over SSH, and wraps the
whole thing in an internet-facing security layer that redamon.sh deliberately does not
provide (RedAmon is designed local-only): nginx + TLS + firewall + host hardening.
The security promise: one public origin over HTTPS. From the internet only the webapp UI (443, plus 80 for the ACME challenge and an HTTP->HTTPS redirect) is reachable. The agent API, MCP servers, databases, orchestrator, and the reverse-shell catcher stay bound to loopback. nginx proxies the webapp and (same-origin) exactly the four agent WebSocket paths; nothing else on the agent is exposed.
Quick start
cd deploy/single-host
cp .env.example .env
# edit .env: HOST_IP, DOMAIN, SSH_KEY_PATH, OPERATOR_ALLOW_CIDRS, LETSENCRYPT_EMAIL,
# ADMIN_NAME / ADMIN_EMAIL / ADMIN_PASSWORD
./deploy.sh init
First build takes 30-60 minutes (Kali + agent images). When it finishes, log in at
https://<your-domain>/ with the admin you set in .env.
Prerequisites
Local machine: bash, ssh, scp, curl. Only for password auth you also need
sshpass (apt install sshpass / brew install hudochenkov/sshpass/sshpass). Key auth is
recommended and needs nothing extra.
The server: a fresh Ubuntu 22.04/24.04 box. You install nothing on it by hand -
deploy.sh detects and installs every prerequisite (Docker Engine + Compose v2, nginx,
certbot, ufw, fail2ban, swap, ...) idempotently. See "How the host is bootstrapped" below.
Server sizing
RedAmon is heavy. redamon.sh enforces a hard 8 GB Docker-visible RAM floor at bring-up
(effective ~7.5 GB after slack), aborting below it unless REDAMON_SKIP_RAM_GATE=true.
| Profile | vCPU | RAM | Disk | Example | Notes |
|---|---|---|---|---|---|
| Bare minimum (core only, no GVM/KB) | 2 | 8 GB | 100 GB | t3.large | Fits the ~90 GB platform footprint with almost no operational headroom. Cap the build cache (DOCKER_BUILD_CACHE_MAX_GB) or the parallel build can hit 100%. Slow/swappy; keep swap on. |
| Recommended (core + real scans, no GVM) | 4 | 16 GB | 200 GB | m5.xlarge | ~90 GB platform + ~100 GB free for operational data. Sane default. |
| Full (GVM + KB + Ollama judge + parallel scans) | 8 | 32 GB | 250 GB | m5.2xlarge | ~120 GB platform + ~100 GB operational. Ollama alone needs ~5-6 GB RAM while scanning. |
Disk sizing. The fixed platform footprint is ~90 GB without GVM (OS ~9 GB + the 13 built
images ~60 GB + build cache ~20 GB) and ~120 GB with GVM (its images plus the
vulnerability-feed volumes/DB add ~25-30 GB, and those grow on every feed update). On top of
that, budget ~100 GB of free space for operational data - the Neo4j attack graph, scan
artifacts/outputs, Postgres, and container logs all grow with use. That lands at 200 GB
without GVM, 250 GB with GVM, each leaving roughly 100 GB free for operations. gp3 EBS
resizes online, so you can start smaller and grow in place. Set DOCKER_BUILD_CACHE_MAX_GB so
the build cache (which update reuses but never prunes) can't slowly eat that headroom.
The .env reference (every variable)
cp .env.example .env, then fill it in. Every key, grouped as in the file:
Connection (CLI positionals override these)
| Key | Purpose | Local-only |
|---|---|---|
HOST_IP | Public IP/DNS of the target. Required. | yes |
REMOTE_USER | SSH sudoer (default ubuntu). | yes |
SSH_KEY_PATH | Path to .pem key (recommended). ~ is expanded. | yes (never copied to host) |
SSH_PASSWORD | Password-auth fallback. Needs local sshpass. Discouraged. | yes |
SSH_PORT | SSH port (default 22). Also drives the host ufw SSH-allow rule and the fail2ban [sshd] jail, so set it to the port the host's sshd actually listens on. | no (shipped to host) |
Repo
| Key | Purpose |
|---|---|
REPO_URL | Public clone URL. No token needed. |
REPO_BRANCH | Branch to deploy (default master). |
APP_DIR | Checkout dir name. Keep stable - it fixes COMPOSE_PROJECT_NAME, which redamon.sh uses for DB-volume detection. Do not change between init and update. |
REDAMON_VERSION | Optional version stamp baked into images. Blank -> redamon.sh default. |
Access mode
| Key | Purpose |
|---|---|
ACCESS_MODE | https-domain (default) | https-ip | http-domain | http-ip. See "Access modes" below. |
DOMAIN | Required for *-domain modes; ignored for *-ip. |
HTTP_PORT / HTTPS_PORT | Ports nginx listens on (and ufw opens). Default 80/443. Keep HTTP_PORT=80 if using Let's Encrypt (http-01 always validates on 80). Change only when fronted by another LB/proxy that presents 80/443 to clients. |
TLS (https-* only)
| Key | Purpose |
|---|---|
TLS_MODE | letsencrypt (domain only) | provided | self-signed (ip only). |
LETSENCRYPT_EMAIL | Required for letsencrypt. |
LETSENCRYPT_STAGING | true issues against LE staging (not browser-trusted) while testing, to avoid the 5-certs/week rate limit. |
SSL_CERT_LOCAL / SSL_KEY_LOCAL | provided mode: local paths (relative to this dir) SCP'd to the host. Drop files in cert/. |
SSL_KEY_PASSWORD | Passphrase if the provided key is encrypted. |
HSTS_ENABLE | Emit HSTS on https (default true). Auto-absent for http-*. |
Access control / hardening
| Key | Purpose |
|---|---|
OPERATOR_ALLOW_CIDRS | Comma list. nginx allow-gate + ufw 443 source. Strongly recommended. |
SSH_ALLOW_CIDRS | Comma list for ufw :22. Blank -> falls back to OPERATOR_ALLOW_CIDRS. |
GATE_MODE | ip_allowlist (recommended) | basic_auth | none. |
BASIC_AUTH_USER / BASIC_AUTH_PASS | Required for basic_auth. |
WS_REQUIRE_SESSION | Default true. nginx auth_request requires a valid webapp session cookie before ANY /ws/* upgrade. Keep it on as defense-in-depth: all four agent WS paths now enforce a signed ws-ticket + a server-side same-origin check at the app layer (fail-closed when AGENT_WS_TICKET_SECRET is unset), and this cookie gate sits in front of that as a second layer. |
CSP_ENFORCE | Default false (Content-Security-Policy-Report-Only). Set true to enforce the CSP after confirming the WebGL graph and xterm terminal still render. |
ENABLE_UFW | Host firewall (default true). |
ENABLE_SSH_HARDENING | Key-only + no-root login. Skips disabling password login if you deployed over a password (so you don't lock yourself out). |
ENABLE_FAIL2BAN | sshd + nginx jails. |
ENABLE_UNATTENDED_UPGRADES | Auto security patches. |
Feature flags (map to redamon.sh)
| Key | Maps to | Notes |
|---|---|---|
ENABLE_GVM | --gvm | Heavy; 10-20 min feed sync; the deploy rotates GVM's admin/admin. |
ENABLE_KB | --kbase | Bakes ML embedding models (+~4.4 GB image). The real KB switch. |
ENABLE_KB_REFRESH | kb-refresh sidecar | |
ENABLE_ZRAM | REDAMON_ENABLE_ZRAM=1 | Compressed-RAM cushion. |
SKIP_KB is intentionally not an input: redamon.sh derives it from the .kbase-enabled
flag it manages. Control KB with ENABLE_KB only.
Resources / tuning
| Key | Purpose |
|---|---|
SWAP_SIZE_GB | Swapfile size when host RAM < 16 GB. 0 skips. |
REDAMON_SKIP_RAM_GATE | true bypasses the 8 GB floor on tiny boxes (risky). |
REDAMON_BUILD_PARALLEL | Cap concurrent image builds. Blank -> redamon.sh auto-sizes. |
DOCKER_DNS | e.g. 8.8.8.8,8.8.4.4 merged into /etc/docker/daemon.json if container DNS breaks. |
DOCKER_BUILD_CACHE_MAX_GB | Cap the BuildKit build cache (GB) via daemon.json auto-GC. Blank -> Docker's default. update reuses but never prunes the cache, so it grows unbounded; set e.g. 30 to bound it without slowing incremental rebuilds. |
<SERVICE>_CPUS | Per-service CPU limit (NEO4J_CPUS, KALI_CPUS, AGENT_CPUS, ...). Auto-capped at init to min(compose default, host nproc) so up never fails on a box with fewer CPUs than compose's generous defaults (neo4j 8 / kali 10 / agent 8). Set one to pin it; blank -> auto. |
Engagement knobs
| Key | Purpose |
|---|---|
REVSHELL_TARGET_CIDRS | Scopes the ufw 4444 rule to your RoE targets. 4444 is loopback-bound by default; see the Security posture section for how to actually catch reverse shells (tunnel, or republish + DOCKER-USER rule). |
TUNNELS_ENABLED | Keep false on an internet-exposed host. |
First admin (required for init)
| Key | Purpose |
|---|---|
ADMIN_NAME / ADMIN_EMAIL / ADMIN_PASSWORD | The first admin user, created automatically during init. There is no app-layer login lockout, so use a strong password (nginx limit_req is the only brute-force brake). |
Optional app config (appended to the server .env)
| Key | Purpose |
|---|---|
NVD_API_KEY | Faster NVD lookups. |
KB_EMBEDDING_USE_API / KB_EMBEDDING_API_BASE_URL / KB_EMBEDDING_API_KEY | Use a remote embedding API instead of the baked local model. |
LLM provider keys are configured in the UI, not here.
Behaviour / safety
| Key | Purpose |
|---|---|
INIT_FORCE | true skips the typed INIT wipe confirmation (CI only - dangerous). |
BACKUP_BEFORE_UPDATE | Reserved (pre-update DB snapshot). |
DRY_RUN | Print the resolved plan and exit without touching the host. |
VERBOSE | Stream full remote output. |
ALLOW_INSECURE | Must be 1 to permit http-* modes. |
Access modes
| Pick | When |
|---|---|
https-domain | You have a real DNS name pointing at the host. The default and recommended. Use TLS_MODE=letsencrypt. |
https-ip | Bare IP, no DNS, but you still want TLS. TLS_MODE=self-signed (browser warning) or provided. |
http-domain / http-ip | Lab/test only. No TLS. Gated behind ALLOW_INSECURE=1 and a loud banner - they invert the whole security posture (plaintext cookies/creds, no HSTS, agent WS unencrypted). |
Derived automatically from ACCESS_MODE (you never set these):
| ACCESS_MODE | Public host | nginx | WS scheme | Secure cookie / HSTS |
|---|---|---|---|---|
https-domain | $DOMAIN | 443 + 80->443 | wss://$DOMAIN/ws/... | on |
https-ip | $HOST_IP | 443 + 80->443 | wss://$HOST_IP/ws/... | on (self-signed warning) |
http-domain | $DOMAIN | 80 only | ws://$DOMAIN/ws/... | off |
http-ip | $HOST_IP | 80 only | ws://$HOST_IP/ws/... | off |
The agent WebSocket URL is baked into the webapp image at build time
(NEXT_PUBLIC_AGENT_WS_URL). Changing the scheme or host later requires a webapp rebuild
(re-run update), not just an nginx reload.
TLS setup
- letsencrypt (recommended,
https-domain): needsDOMAINresolving to the host and port 80 reachable. The deploy stands up an ACME webroot on 80, runscertbot certonly --webroot, then installs the hardened 443 vhost pointing at/etc/letsencrypt/live/$DOMAIN/. Auto-renew is wired via certbot's timer + an nginx reload hook. SetLETSENCRYPT_STAGING=truewhile testing. - provided (
https-domainorhttps-ip): dropfullchain.pem+privkey.pemincert/(or pointSSL_CERT_LOCAL/SSL_KEY_LOCALat them). They are SCP'd to/etc/ssl/redamon/(key600), md5-idempotent. The only way to a trusted cert on a bare IP. - self-signed (
https-ipescape hatch): generated on the host with the IP in a SAN. Browser warning expected. Not for production. - No TLS (
http-*): certbot is skipped entirely.
Commands
| Command | What it does | Destructive |
|---|---|---|
./deploy.sh init | Wipe ALL Docker state + the checkout, then build from zero. Auto-creates the first admin from .env. | yes (typed INIT confirm) |
./deploy.sh update | Pull latest REPO_BRANCH HEAD and apply (diff-driven rebuild via redamon.sh). Preserves all volumes/data. | no |
./deploy.sh status | redamon.sh status + docker compose ps + ufw + nginx -t + cert expiry. | no |
./deploy.sh harden | Re-apply host hardening + nginx/TLS only (idempotent), no rebuild. | no |
./deploy.sh ssl-renew | Renew (certbot) or re-install (provided) the cert + reload nginx. | no |
./deploy.sh down | Stop the stack, keep volumes/images. | no |
./deploy.sh logs [service] | Tail docker compose logs -f for a service (default agent). | no |
./deploy.sh revshell-open | Per-engagement: expose 4444 to REVSHELL_TARGET_CIDRS via a host socat forwarder (fails closed on reboot). | no |
./deploy.sh revshell-close | Tear the 4444 forwarder down and remove its ufw rules. | no |
Connection fields can be overridden positionally, e.g.
./deploy.sh init 1.2.3.4 ~/.ssh/redamon.pem ubuntu. Use --env NAME to select
.env.NAME for per-instance configs (prod, staging, a client name).
Security posture
RedAmon's threat model assumes a local-only deployment with no anonymous internet attacker. Putting it on a public IP invalidates that, so the deploy closes the gap:
- Parts of the agent REST API remain unauthenticated (the whole
/workspace/*family,/sessions/*,/files, FastAPI/docs+/openapi.json);/graph/execand/emergency-stop-allnow requirerequire_internal_auth(wave 2). All four WS paths now enforce a signed ws-ticket + a server-side same-origin check and fail closed whenAGENT_WS_TICKET_SECRETis unset - including/ws/kali-terminal(the root PTY) and the two cypherfix sockets that clone repos / edit code. The deploy still treats the agent as fully untrusted from the edge:- nginx proxies only the four
/ws/*paths to the agent - never any agent REST route. auth_request(WS_REQUIRE_SESSION=true) makes nginx validate the webapp session cookie against/api/auth/mebefore allowing any WS upgrade, so reaching those agent sockets requires a logged-in user - not just a network position - on top of the app-layer ws-ticket.- the operator IP allowlist / basic-auth gate sits in front of everything.
- nginx proxies only the four
- The webapp trusts
X-Internal-Keyas a service-to-service auth bypass and injectsX-User-Id/X-User-Roledownstream after auth. nginx strips these inbound headers on every request (plusX-Powered-By) so a client can never spoof identity or bypass auth. - There is no login rate-limit or lockout in the app. nginx supplies
limit_reqon/api/auth/login(5r/m) and a general/api/limit (30r/s), a per-IP connection cap, and slowloris timeouts. - Headers: the webapp sets none of its own, so nginx is authoritative - HSTS, a tightened
CSP (no
unsafe-eval;object-src 'none';base-uri/form-action 'self'),X-Frame-Options DENY,X-Content-Type-Options,Referrer-Policy,Permissions-Policy, and COOP/CORP. - TLS: TLS 1.2/1.3 only, modern ECDHE ciphers,
ssl_session_tickets off, OCSP stapling. - Every secret must be strong: the deploy runs a secrets gate that fails the build on any
unset/default/short secret (
changeme,redamon_secret, ...). redamon.sh generates them; the deploy only verifies. ip_allowlist(defaultGATE_MODE) is strongly recommended given the powerful agent WS surface + root PTY - defense-in-depth on top of the app-layer ws-ticket and login.- Reverse shell (4444) is bound to loopback by the prod overlay and closed at the
firewall. To catch a direct reverse shell during an engagement, set
REVSHELL_TARGET_CIDRSto your RoE target scope and run./deploy.sh revshell-open; run./deploy.sh revshell-closethe moment you are done. How it works:revshell-openstarts a transientsocatforwarder on the host's public IP (<public-ip>:4444 -> 127.0.0.1:4444) and opens4444in ufw to your target CIDRs only. Becausesocatis a host listener (not a Docker-published port), ufw's source scoping actually applies - a plain ufw rule on a Docker-published port would be bypassed by Docker's own iptables chains. A host reboot drops the forwarder, so 4444 fails closed. The msf handler on 4444 is unauthenticated, so it is exposed only toREVSHELL_TARGET_CIDRS, never the world; set your payload's LHOST to the host's public IP and start the msf handler in RedAmon. (Alternative: a tunnel ngrok/chisel to127.0.0.1:4444withTUNNELS_ENABLED=true; never run a world-open 4444 and a tunnel at once.) - If
ENABLE_GVM=true, the deploy rotates GVM's defaultadmin/adminand prints the new password once.
Cloud firewall / Security Group
deploy.sh configures the host ufw, but on a cloud host your provider firewall (AWS
Security Group / GCP firewall / Azure NSG) is the reliable outer boundary — Docker's own
iptables chains can bypass ufw, so the cloud firewall is what still protects you if a
loopback re-bind is ever wrong (e.g. a stray docker compose up that republishes
3000/8090/4444 on 0.0.0.0). Open only:
| Port | Source | Purpose |
|---|---|---|
| 443/tcp | your OPERATOR_ALLOW_CIDRS (or 0.0.0.0/0 if you rely on the nginx gate) | the app (HTTPS) |
| 80/tcp | 0.0.0.0/0 | Let's Encrypt ACME challenge + redirect to 443 (required for TLS_MODE=letsencrypt) |
| 22/tcp | your IP only | SSH management |
Open nothing else. In particular do not open 8090 (agent), 3000 (webapp),
5432/7687/7474 (PostgreSQL/Neo4j), or 8000-8016/4444/8015 (Kali MCP / progress /
tunnel manager / PTY / reverse-shell) — all are loopback-bound and reached only through nginx
on 443. Opening 8090 would expose the agent's unauthenticated WebSockets (including the
anonymous root PTY /ws/kali-terminal) and its REST surface directly, bypassing nginx and
its session gate. The browser reaches the agent's WebSockets at wss://<domain>/ws/… on 443
(nginx → 127.0.0.1:8090), so 8090 never needs to be publicly reachable.
nginx runs on the host, not in a container. It sits only at the edge (80/443), reverse-proxying to two loopback backends — the webapp (
127.0.0.1:3000, for/and/api/*) and the agent (127.0.0.1:8090, for the four/ws/*paths only). It is not a middlebox between containers: all internal service-to-service traffic (webapp ↔ agent ↔ orchestrator ↔ DBs ↔ Kali) flows directly over the Docker bridge networks and never passes through nginx.
First-run walkthrough
cp .env.example .env; setHOST_IP,DOMAIN,SSH_KEY_PATH,OPERATOR_ALLOW_CIDRS,LETSENCRYPT_EMAIL, andADMIN_NAME/ADMIN_EMAIL/ADMIN_PASSWORD. PointDOMAIN's A-record atHOST_IPfirst (letsencrypt needs it)../deploy.sh init-> typeINITto confirm the wipe.- Watch: teardown -> host bootstrap -> hardening -> clone + overlay ->
redamon.sh install(30-60 min build) -> secrets gate -> admin bootstrap -> nginx + TLS -> verify. - Verification asserts loopback binds (3000/8090 on 127.0.0.1 only), datastores never
off-loopback, container health, an admin exists, and
https://<domain>/api/health-> 200. - Log in at
https://<domain>/.
DRY_RUN=true ./deploy.sh init prints the resolved plan without touching the host.
How the host is bootstrapped
init (and harden) run a dynamic detect-and-install matrix - each item is probed and
installed only if missing or too old, idempotently: base apt packages (git, openssl, curl,
jq, expect, ...), Docker Engine + Compose v2 from Docker's official repo (purging any old
docker.io/v1 compose first), the docker service + group membership, nginx, certbot (only
when letsencrypt is used), ufw, fail2ban, unattended-upgrades, an 8 GB swapfile on < 16 GB
hosts, optional Docker DNS, and inotify limits. Nothing is assumed pre-present.
Update and rollback
update is a thin wrapper around redamon.sh update: git pull --ff-only + diff-driven
selective rebuild + secret regen (all via redamon.sh, which preserves DB passwords by volume
detection), then re-renders nginx. No source patches are applied: the single-origin agent WS
URL is a first-class ARG NEXT_PUBLIC_AGENT_WS_URL in the base webapp/Dockerfile, baked from
the prod overlay's build-arg, so redamon.sh's own webapp rebuild bakes the right value.
All engagement data (Postgres, Neo4j, reports, GVM feeds, models) lives in named volumes
that survive update.
The prod compose overlay (compose/docker-compose.prod.yml) is SCP'd to the host and installed
at $HOME/.redamon-deploy (outside the checkout, so it never blocks git pull --ff-only). It
sets NEXT_PUBLIC_AGENT_WS_URL=wss://<host>/ws/agent as the webapp build-arg. The deploy works
even if deploy/single-host/ is not committed to the cloned branch (committing it is still fine
and makes the overlay part of the repo history).
Rollback is manual: git reset --hard <prev> on the host checkout, then ./deploy.sh update.
Troubleshooting and FAQ
- RAM gate abort ("need ~8 GB"): provision more RAM, or set
REDAMON_SKIP_RAM_GATE=true(risky) and keepSWAP_SIZE_GBon. - certbot fails:
DOMAINmust resolve to the host and port 80 must be reachable (open it in your cloud Security Group /OPERATOR_ALLOW_CIDRSmust not block the ACME check - LE validates from arbitrary IPs, so 80 must be world-open). UseLETSENCRYPT_STAGING=trueto iterate without burning the rate limit. - Secrets-gate failure: a secret is unset/default/short. redamon.sh should have generated
them; check the server
~/<APP_DIR>/.env. - GVM feed-sync wait: with
ENABLE_GVM=true, scans don't work until feeds finish syncing (10-20 min after first boot). - Never run raw
docker compose upon the host. The loopback re-binds hold only while the prod overlay is active (COMPOSE_FILE). A baredocker compose upreloads the base file and republishes 3000/8090/4444 on 0.0.0.0 and starts the profile-less GVM stack. Always go throughredamon.sh(which the deploy points at the overlay).redamon.sh up devis likewise forbidden here (it hardcodes its own compose files and defeats the overlay). - Locked out over SSH: if you deployed over a password,
ENABLE_SSH_HARDENINGwill not disable password login. Install a key, then re-runhardenwith key auth to close it.
The redamon.sh parameter surface
deploy.sh never edits redamon.sh. It drives it through four channels: the subcommand
(MODE), CLI flags on install, exported env vars, and keys appended to the server-side
application .env.
.env key | Drives |
|---|---|
ENABLE_GVM | install --gvm |
ENABLE_KB | install --kbase |
ENABLE_ZRAM | REDAMON_ENABLE_ZRAM=1 |
REDAMON_BUILD_PARALLEL | REDAMON_BUILD_PARALLEL (blank -> auto) |
DOCKER_BUILD_CACHE_MAX_GB | builder.gc.defaultKeepStorage in /etc/docker/daemon.json (blank -> Docker default) |
REDAMON_SKIP_RAM_GATE | REDAMON_SKIP_RAM_GATE=1 |
REDAMON_VERSION | image build-arg stamp |
| (fixed) | COMPOSE_FILE = base + prod overlay, made sticky |
APP_DIR | COMPOSE_PROJECT_NAME (do not change between init/update) |
ADMIN_* | first-admin creation on init |
NVD_API_KEY, KB_EMBEDDING_*, TUNNELS_ENABLED | appended to the server .env |
Intentionally hands-off (redamon.sh owns these; the deploy only verifies): AUTH_SECRET,
INTERNAL_API_KEY, SCANNER_API_KEY, ORCHESTRATOR_API_KEY, MCP_AUTH_TOKEN,
AGENT_WS_TICKET_SECRET, TUNNEL_AUTH_TOKEN, POSTGRES_PASSWORD, NEO4J_PASSWORD, and
SKIP_KB (derived from the .kbase-enabled flag).