Deployment scripts for running Event Mill on Google Cloud Run with a ttyd web terminal frontend.
Browser (HTTPS:443) → Cloud Run → ttyd (:8080) → eventmill CLI shell
Cloud Run provides automatic HTTPS, scaling (0→N), and IAM-based access control. The ttyd web terminal gives analysts a browser-based Metasploit-style shell.
Deployments are run from a dedicated Linux server with the Google Cloud SDK and libraries pre-installed. The workflow is:
1. SSH into Linux deploy server
2. Pull latest code from GitHub
3. Authenticate to GCP (if session expired)
4. Run deploy script (deploy config is loaded automatically)
ssh deploy-server
cd ~/eventmill_v01
git pull
bash cloud_install/deploy-cloudrun-secrets.sh
~/.eventmill/deploy.env is loaded automatically — anything already exported
in your shell wins over it. Only gcloud builds submit still needs an explicit
source, because Cloud Build cannot read your shell.
Run the bootstrap script once on the Linux deploy server:
# Download and run directly, or clone first
curl -sL https://raw.githubusercontent.com/eventmilldevops/eventmill_v01/main/cloud_install/setup-deploy-server.sh | bash
Or manually:
git clone https://github.com/eventmilldevops/eventmill_v01.git ~/eventmill_v01
bash ~/eventmill_v01/cloud_install/setup-deploy-server.sh
This will:
gcloud and docker are available~/eventmill_v01~/.eventmill/deploy.env config templateThen configure:
nano ~/.eventmill/deploy.env # GOOGLE_CLOUD_PROJECT and CLOUD_RUN_REGION are required and blank
gcloud auth login # Authenticate to GCP
gcloud config set project YOUR_PROJECT_ID
provision-gcp-project.sh and deploy-cloudrun-secrets.sh load
~/.eventmill/deploy.env automatically — anything already exported in your
shell takes precedence. The explicit source in the examples below is harmless
and still required for gcloud builds submit, which cannot read the file.
Important:
EVENTMILL_BUCKET_PREFIXmust match the prefix used when runningprovision-gcp-project.sh. Storage resolution will silently use the wrong buckets if this value is missing or mismatched.
CLOUD_RUN_REGIONmust match too, and is left blank in the template on purpose. Every script now refuses to guess it: the Artifact Registry image path embeds the region, so provisioning in one and deploying in another fails atdocker push— after a full paid build.
source ~/.eventmill/deploy.env
cd ~/eventmill_v01
git pull
bash cloud_install/deploy-cloudrun-secrets.sh
source ~/.eventmill/deploy.env
export GEMINI_FLASH_API_KEY="your-flash-key"
export GEMINI_PRO_API_KEY="your-pro-key"
export TTYD_USERNAME="admin"
export TTYD_PASSWORD="changeme"
cd ~/eventmill_v01
bash cloud_install/deploy-cloudrun.sh
Connect GitHub repo to Cloud Build, then trigger manually:
cd ~/eventmill_v01
source ~/.eventmill/deploy.env
gcloud builds submit \
--project="${GOOGLE_CLOUD_PROJECT}" \
--config=cloud_install/cloudbuild.yaml \
--substitutions="_REGION=${CLOUD_RUN_REGION},_BUCKET_PREFIX=${EVENTMILL_BUCKET_PREFIX}" \
.
Substitutions:
| Substitution | Default | Description |
|---|---|---|
_REGION |
none — required | Cloud Run region. Must match the region you provisioned in; the build fails fast if unset rather than guessing. |
_BUCKET_PREFIX |
${PROJECT_ID}-eventmill |
GCS bucket prefix — must match provisioned buckets |
Cloud Build cannot read ~/.eventmill/deploy.env, so _REGION has to be passed
explicitly here even though the shell scripts pick it up automatically. When
wiring a trigger, set both in the trigger’s substitution config.
| File | Purpose |
|---|---|
provision-gcp-project.sh |
Run first — enables APIs, creates SA, bucket, secrets |
provision-secrets.sh |
Interactive — sets real values for Secret Manager entries |
setup-deploy-server.sh |
One-time bootstrap for the Linux deploy server |
Dockerfile.cloudrun |
Multi-stage container image with ttyd + eventmill |
deploy-cloudrun.sh |
Basic Cloud Run deploy (env var secrets) |
deploy-cloudrun-secrets.sh |
Production deploy with GCP Secret Manager |
cloudbuild.yaml |
Cloud Build CI/CD pipeline |
docker-compose.cloudrun.yml |
Local testing of the Cloud Run image |
*.bak |
Superseded v1 scripts, kept for reference only — do not run |
export GOOGLE_CLOUD_PROJECT="your-project-id"
export CLOUD_RUN_REGION="us-central1" # required — no default
export EVENTMILL_BUCKET_PREFIX="your-prefix" # optional; derived if unset
# 1. Provision APIs, service account, buckets, Artifact Registry, secret entries
bash cloud_install/provision-gcp-project.sh
# 2. Set real secret values (interactive prompts, nothing in shell history)
bash cloud_install/provision-secrets.sh
This creates everything the project needs: APIs enabled (including
apikeys.googleapis.com for key creation and logging.googleapis.com for
audit logging), a dedicated service account with least-privilege IAM roles,
GCS buckets (per-pillar + common) with lifecycle rules, Artifact Registry,
and Secret Manager entries for dual Gemini API keys.
Provisioning is the only place IAM is written. The deploy scripts merely
verify, so the deploy path needs no *.setIamPolicy permission and can run
under a CI service account that must not be able to rewrite IAM.
When it finishes it prints the exact ~/.eventmill/deploy.env to save, with
the resolved project, region and bucket prefix filled in. The provisioning and
deploy scripts both read that file automatically on subsequent runs.
The deployed service binds two model tiers, configured declaratively in
framework/llm/providers/gcp_gemini.json:
| Tier | Model | Used for |
|---|---|---|
| light | gemini-3.8-flash |
Bulk work — log pattern summarization, per-chunk IOC extraction, report chunking |
| heavy | gemini-3.1-pro-preview |
Deep reasoning — threat modeling, risk assessment, cross-document synthesis, ask: |
Both accept 1,048,576 input and 65,536 output tokens, so the tier is a choice
about reasoning depth and cost, never about how much fits. Each plugin declares
its default tier as model_tier in its manifest; a plugin can override per call.
Keys are separate per tier so high-volume light-tier traffic cannot exhaust the heavy tier’s quota. If one tier is exhausted, the dispatcher falls back to the other and says so in the log.
The heavy tier is pinned to a Preview endpoint, which Google may retire with roughly two weeks’ notice. If it starts returning
NOT_FOUND, the dispatcher automatically retries against the tier’s declared fallback and logs the substitution. To pin a different model without a code change, setEVENTMILL_MODEL_HEAVY.
The light tier also declares a fallback, for a different reason: 3.8 Flash’s id and token caps have not yet been confirmed against the live API, so a wrong id lands on
gemini-3.5-flashinstead of failing the call. If you repoint it withEVENTMILL_MODEL_LIGHT, setEVENTMILL_MAX_OUTPUT_LIGHTtoo — the output cap otherwise still comes from the manifest.
Event Mill uses per-pillar GCS buckets for data isolation plus a shared common bucket for cross-pillar reference data (e.g. vetted threat intel).
{EVENTMILL_BUCKET_PREFIX}-log-analysis ← log analysis artifacts
{EVENTMILL_BUCKET_PREFIX}-network-forensics ← network forensics artifacts
{EVENTMILL_BUCKET_PREFIX}-threat-modeling ← threat modeling artifacts
{EVENTMILL_BUCKET_PREFIX}-common ← shared reference data
Default prefix: {your-project-id}-eventmill (auto-derived from GOOGLE_CLOUD_PROJECT). Set EVENTMILL_BUCKET_PREFIX to override.
Buckets can contain workspace folders to separate incidents:
gs://eventmill-log-analysis/
├── incident-2024-03/
│ ├── auth.log
│ └── syslog.log
├── incident-2024-04/
│ └── firewall.log
└── standalone-file.log ← bucket root (no workspace)
In the CLI, use workspace incident-2024-03 to scope file resolution.
The load command checks both the pillar bucket and the common bucket.
When a user runs load auth.log:
If both pillar and common have the file, pillar wins (investigation-specific data takes precedence over shared reference data).
External automations write directly to the appropriate pillar bucket. Which automations write to which buckets is site-specific and managed by the implementation team outside of Event Mill. The common bucket is for curated reference data shared across all investigations.
Override any pillar bucket name via environment variable:
export EVENTMILL_BUCKET_LOG_ANALYSIS="my-custom-log-bucket"
export EVENTMILL_BUCKET_COMMON="my-shared-data"
Cloud Build uses the project’s default compute service account to upload
source tarballs to GCS. If you see a storage.objects.get permission error
during gcloud builds submit, the default compute SA needs storage access.
The provision-gcp-project.sh script handles this automatically. To fix
manually or verify:
# 1. Find your project number
PROJECT_NUMBER=$(gcloud projects describe ${GOOGLE_CLOUD_PROJECT} --format="value(projectNumber)")
# 2. The default compute SA follows this pattern:
# {PROJECT_NUMBER}-compute@developer.gserviceaccount.com
echo "Default compute SA: ${PROJECT_NUMBER}-compute@developer.gserviceaccount.com"
# 3. Grant storage access (source tarball upload)
gcloud projects add-iam-policy-binding ${GOOGLE_CLOUD_PROJECT} \
--member="serviceAccount:${PROJECT_NUMBER}-compute@developer.gserviceaccount.com" \
--role="roles/storage.objectAdmin" \
--quiet
# 4. Grant Artifact Registry access (Docker image push)
gcloud projects add-iam-policy-binding ${GOOGLE_CLOUD_PROJECT} \
--member="serviceAccount:${PROJECT_NUMBER}-compute@developer.gserviceaccount.com" \
--role="roles/artifactregistry.writer" \
--quiet
provision-secrets.sh handles this automatically — it creates restricted
Gemini API keys via gcloud services api-keys create and stores them in
Secret Manager. To manage secrets manually:
# Dual Gemini API keys (restricted to generativelanguage.googleapis.com)
# Display names match the OS env vars for traceability:
# GEMINI_FLASH_API_KEY → eventmill-gemini-flash-api
# GEMINI_PRO_API_KEY → eventmill-gemini-pro-api
# Anthropic / OpenAI keys — issued in those vendors' own consoles, so there is
# no `gcloud services api-keys create` equivalent. One key per provider: neither
# vendor splits keys by tier the way the Gemini pair does.
# ANTHROPIC_API_KEY -> eventmill-anthropic-api
# OPENAI_API_KEY -> eventmill-openai-api
# ttyd basic auth credentials
echo -n "analyst" | gcloud secrets versions add eventmill-ttyd-user --data-file=-
echo -n "strong-password" | gcloud secrets versions add eventmill-ttyd-cred --data-file=-
placeholdereventmill-anthropic-api and eventmill-openai-api are provisioned, IAM-bound
and mounted for every deployment, holding placeholder until someone adopts
that vendor. The build and the deploy are then identical for every project, and
adopting a provider later is a new secret version plus a restart — no
infrastructure change, no rebuild, no different deploy path.
EVENTMILL_LLM_PROVIDERS names the ones a session may bind, and every deploy
path defaults it to all three. A provider whose key is absent or still holds
placeholder is skipped at startup and reported as dormant, so naming all
three costs nothing — and leaving one out would mean a vendor with a real key
in Secret Manager never binds, with no error to show for it.
Step 4 of the deploy therefore checks the values, not the list: it reports
each dormant secret, and blocks only if ttyd would deploy with the password
placeholder or if no LLM provider holds a real key at all.
A mounted key is not automatically a bound provider — but a real one now is.
connect walks every provider named in EVENTMILL_LLM_PROVIDERS (all three by
default) and binds each tier whose key holds a real value, so a genuine
Anthropic key in Secret Manager appears in models and can be selected with
use anthropic for <tool>. A key that is absent or still placeholder is
skipped and reported. providers shows the whole picture; `providers probe