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.

ProfilevCPURAMDiskExampleNotes
Bare minimum (core only, no GVM/KB)28 GB100 GBt3.largeFits 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)416 GB200 GBm5.xlarge~90 GB platform + ~100 GB free for operational data. Sane default.
Full (GVM + KB + Ollama judge + parallel scans)832 GB250 GBm5.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)

KeyPurposeLocal-only
HOST_IPPublic IP/DNS of the target. Required.yes
REMOTE_USERSSH sudoer (default ubuntu).yes
SSH_KEY_PATHPath to .pem key (recommended). ~ is expanded.yes (never copied to host)
SSH_PASSWORDPassword-auth fallback. Needs local sshpass. Discouraged.yes
SSH_PORTSSH 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

KeyPurpose
REPO_URLPublic clone URL. No token needed.
REPO_BRANCHBranch to deploy (default master).
APP_DIRCheckout 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_VERSIONOptional version stamp baked into images. Blank -> redamon.sh default.

Access mode

KeyPurpose
ACCESS_MODEhttps-domain (default) | https-ip | http-domain | http-ip. See "Access modes" below.
DOMAINRequired for *-domain modes; ignored for *-ip.
HTTP_PORT / HTTPS_PORTPorts 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)

KeyPurpose
TLS_MODEletsencrypt (domain only) | provided | self-signed (ip only).
LETSENCRYPT_EMAILRequired for letsencrypt.
LETSENCRYPT_STAGINGtrue issues against LE staging (not browser-trusted) while testing, to avoid the 5-certs/week rate limit.
SSL_CERT_LOCAL / SSL_KEY_LOCALprovided mode: local paths (relative to this dir) SCP'd to the host. Drop files in cert/.
SSL_KEY_PASSWORDPassphrase if the provided key is encrypted.
HSTS_ENABLEEmit HSTS on https (default true). Auto-absent for http-*.

Access control / hardening

KeyPurpose
OPERATOR_ALLOW_CIDRSComma list. nginx allow-gate + ufw 443 source. Strongly recommended.
SSH_ALLOW_CIDRSComma list for ufw :22. Blank -> falls back to OPERATOR_ALLOW_CIDRS.
GATE_MODEip_allowlist (recommended) | basic_auth | none.
BASIC_AUTH_USER / BASIC_AUTH_PASSRequired for basic_auth.
WS_REQUIRE_SESSIONDefault 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_ENFORCEDefault false (Content-Security-Policy-Report-Only). Set true to enforce the CSP after confirming the WebGL graph and xterm terminal still render.
ENABLE_UFWHost firewall (default true).
ENABLE_SSH_HARDENINGKey-only + no-root login. Skips disabling password login if you deployed over a password (so you don't lock yourself out).
ENABLE_FAIL2BANsshd + nginx jails.
ENABLE_UNATTENDED_UPGRADESAuto security patches.

Feature flags (map to redamon.sh)

KeyMaps toNotes
ENABLE_GVM--gvmHeavy; 10-20 min feed sync; the deploy rotates GVM's admin/admin.
ENABLE_KB--kbaseBakes ML embedding models (+~4.4 GB image). The real KB switch.
ENABLE_KB_REFRESHkb-refresh sidecar
ENABLE_ZRAMREDAMON_ENABLE_ZRAM=1Compressed-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

KeyPurpose
SWAP_SIZE_GBSwapfile size when host RAM < 16 GB. 0 skips.
REDAMON_SKIP_RAM_GATEtrue bypasses the 8 GB floor on tiny boxes (risky).
REDAMON_BUILD_PARALLELCap concurrent image builds. Blank -> redamon.sh auto-sizes.
DOCKER_DNSe.g. 8.8.8.8,8.8.4.4 merged into /etc/docker/daemon.json if container DNS breaks.
DOCKER_BUILD_CACHE_MAX_GBCap 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>_CPUSPer-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

KeyPurpose
REVSHELL_TARGET_CIDRSScopes 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_ENABLEDKeep false on an internet-exposed host.

First admin (required for init)

KeyPurpose
ADMIN_NAME / ADMIN_EMAIL / ADMIN_PASSWORDThe 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)

KeyPurpose
NVD_API_KEYFaster NVD lookups.
KB_EMBEDDING_USE_API / KB_EMBEDDING_API_BASE_URL / KB_EMBEDDING_API_KEYUse a remote embedding API instead of the baked local model.

LLM provider keys are configured in the UI, not here.

Behaviour / safety

KeyPurpose
INIT_FORCEtrue skips the typed INIT wipe confirmation (CI only - dangerous).
BACKUP_BEFORE_UPDATEReserved (pre-update DB snapshot).
DRY_RUNPrint the resolved plan and exit without touching the host.
VERBOSEStream full remote output.
ALLOW_INSECUREMust be 1 to permit http-* modes.

Access modes

PickWhen
https-domainYou have a real DNS name pointing at the host. The default and recommended. Use TLS_MODE=letsencrypt.
https-ipBare IP, no DNS, but you still want TLS. TLS_MODE=self-signed (browser warning) or provided.
http-domain / http-ipLab/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_MODEPublic hostnginxWS schemeSecure cookie / HSTS
https-domain$DOMAIN443 + 80->443wss://$DOMAIN/ws/...on
https-ip$HOST_IP443 + 80->443wss://$HOST_IP/ws/...on (self-signed warning)
http-domain$DOMAIN80 onlyws://$DOMAIN/ws/...off
http-ip$HOST_IP80 onlyws://$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): needs DOMAIN resolving to the host and port 80 reachable. The deploy stands up an ACME webroot on 80, runs certbot 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. Set LETSENCRYPT_STAGING=true while testing.
  • provided (https-domain or https-ip): drop fullchain.pem + privkey.pem in cert/ (or point SSL_CERT_LOCAL/SSL_KEY_LOCAL at them). They are SCP'd to /etc/ssl/redamon/ (key 600), md5-idempotent. The only way to a trusted cert on a bare IP.
  • self-signed (https-ip escape 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

CommandWhat it doesDestructive
./deploy.sh initWipe ALL Docker state + the checkout, then build from zero. Auto-creates the first admin from .env.yes (typed INIT confirm)
./deploy.sh updatePull latest REPO_BRANCH HEAD and apply (diff-driven rebuild via redamon.sh). Preserves all volumes/data.no
./deploy.sh statusredamon.sh status + docker compose ps + ufw + nginx -t + cert expiry.no
./deploy.sh hardenRe-apply host hardening + nginx/TLS only (idempotent), no rebuild.no
./deploy.sh ssl-renewRenew (certbot) or re-install (provided) the cert + reload nginx.no
./deploy.sh downStop the stack, keep volumes/images.no
./deploy.sh logs [service]Tail docker compose logs -f for a service (default agent).no
./deploy.sh revshell-openPer-engagement: expose 4444 to REVSHELL_TARGET_CIDRS via a host socat forwarder (fails closed on reboot).no
./deploy.sh revshell-closeTear 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/exec and /emergency-stop-all now require require_internal_auth (wave 2). All four WS paths now enforce a signed ws-ticket + a server-side same-origin check and fail closed when AGENT_WS_TICKET_SECRET is 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/me before 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.
  • The webapp trusts X-Internal-Key as a service-to-service auth bypass and injects X-User-Id / X-User-Role downstream after auth. nginx strips these inbound headers on every request (plus X-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_req on /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 (default GATE_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_CIDRS to your RoE target scope and run ./deploy.sh revshell-open; run ./deploy.sh revshell-close the moment you are done. How it works: revshell-open starts a transient socat forwarder on the host's public IP (<public-ip>:4444 -> 127.0.0.1:4444) and opens 4444 in ufw to your target CIDRs only. Because socat is 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 to REVSHELL_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 to 127.0.0.1:4444 with TUNNELS_ENABLED=true; never run a world-open 4444 and a tunnel at once.)
  • If ENABLE_GVM=true, the deploy rotates GVM's default admin/admin and 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:

PortSourcePurpose
443/tcpyour OPERATOR_ALLOW_CIDRS (or 0.0.0.0/0 if you rely on the nginx gate)the app (HTTPS)
80/tcp0.0.0.0/0Let's Encrypt ACME challenge + redirect to 443 (required for TLS_MODE=letsencrypt)
22/tcpyour IP onlySSH 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

  1. cp .env.example .env; set HOST_IP, DOMAIN, SSH_KEY_PATH, OPERATOR_ALLOW_CIDRS, LETSENCRYPT_EMAIL, and ADMIN_NAME/ADMIN_EMAIL/ADMIN_PASSWORD. Point DOMAIN's A-record at HOST_IP first (letsencrypt needs it).
  2. ./deploy.sh init -> type INIT to confirm the wipe.
  3. Watch: teardown -> host bootstrap -> hardening -> clone + overlay -> redamon.sh install (30-60 min build) -> secrets gate -> admin bootstrap -> nginx + TLS -> verify.
  4. 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.
  5. 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 keep SWAP_SIZE_GB on.
  • certbot fails: DOMAIN must resolve to the host and port 80 must be reachable (open it in your cloud Security Group / OPERATOR_ALLOW_CIDRS must not block the ACME check - LE validates from arbitrary IPs, so 80 must be world-open). Use LETSENCRYPT_STAGING=true to 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 up on the host. The loopback re-binds hold only while the prod overlay is active (COMPOSE_FILE). A bare docker compose up reloads the base file and republishes 3000/8090/4444 on 0.0.0.0 and starts the profile-less GVM stack. Always go through redamon.sh (which the deploy points at the overlay). redamon.sh up dev is 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_HARDENING will not disable password login. Install a key, then re-run harden with 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 keyDrives
ENABLE_GVMinstall --gvm
ENABLE_KBinstall --kbase
ENABLE_ZRAMREDAMON_ENABLE_ZRAM=1
REDAMON_BUILD_PARALLELREDAMON_BUILD_PARALLEL (blank -> auto)
DOCKER_BUILD_CACHE_MAX_GBbuilder.gc.defaultKeepStorage in /etc/docker/daemon.json (blank -> Docker default)
REDAMON_SKIP_RAM_GATEREDAMON_SKIP_RAM_GATE=1
REDAMON_VERSIONimage build-arg stamp
(fixed)COMPOSE_FILE = base + prod overlay, made sticky
APP_DIRCOMPOSE_PROJECT_NAME (do not change between init/update)
ADMIN_*first-admin creation on init
NVD_API_KEY, KB_EMBEDDING_*, TUNNELS_ENABLEDappended 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).