Deployment Guide (Non-Production)¶
Single EC2 instance running the full Floh stack via Docker Compose with Caddy for automatic HTTPS.
TLS by Tier¶
| Tier | TLS mode | How configured |
|---|---|---|
Edge (caddy) |
HTTPS on :443, certs from Let's Encrypt |
DEPLOY_DOMAIN, DEPLOY_PORTAL_DOMAIN, DEPLOY_FORM_BUILDER_DOMAIN |
| Admin/portal/form-builder browser traffic | HTTPS at public domains | handled by Caddy reverse proxy |
Internal container traffic (server, portal-bff, web, portal-web, form-builder) |
HTTP on Docker network | default compose networking (server:7070, etc.) |
| OIDC redirect / post-logout URLs | HTTPS public URLs | public/ci.env (OIDC_REDIRECT_URI, FRONTEND_URL, PORTAL_FRONTEND_URL) |
Notes:
- Do not set
TLS_CERT_FILE/TLS_KEY_FILEfor deploy compose. API TLS terminates at Caddy. FLOH_INTERNAL_URLin public config should stayhttp://server:7070for Docker deploy.- The Floh server runs a periodic TLS health check (every 15 min). If a domain's cert is missing or invalid, the server reloads Caddy via its admin API to trigger a fresh ACME challenge. Set
CADDY_ADMIN_URL=http://caddy:2019inci.envto enable (already configured).
Architecture¶
Internet
│
├─ :443 ──► Caddy (TLS termination, Let's Encrypt)
│ ├─ floh-dev.example.com/api/* ──► server:7070
│ ├─ floh-dev.example.com/* ──► web:8080 (nginx)
│ ├─ portal.example.com/api/* ──► portal-bff:7071
│ ├─ portal.example.com/* ──► portal-web:8080 (nginx)
│ └─ forms.example.com/* ──► form-builder:8080 (nginx)
│ (CSP frame-ancestors pinned to floh-dev.example.com)
│
└─ :8025 ──► MailHog UI (restrict to your IP)
The form-builder site is the standalone
@floh/form-builder-app
SPA, iframed by the Floh admin UI to power the Workflow Designer's
Visual editor. It MUST live on an origin distinct from
DEPLOY_DOMAIN — the host class rejects same-origin embeds at
runtime. The deploy workflow bakes
https://${DEPLOY_FORM_BUILDER_DOMAIN}/ into the web image as
environment.formBuilderEmbedUrl so the Visual toggle is enabled
out of the box.
Prerequisites¶
- AWS account
- A domain name with DNS you can control
- GitHub repo:
github.com/AxleResearch/floh
Step 1: Launch EC2 Instance¶
- Instance type:
t3.medium(2 vCPU, 4 GB RAM) - AMI: Ubuntu 24.04 LTS
- Storage: 30 GB gp3 EBS
- Key pair: Create or select one (save the
.pemfile)
Step 2: Security Group¶
Allow inbound:
| Port | Protocol | Source | Purpose |
|---|---|---|---|
| 22 | TCP | Your IP | SSH |
| 80 | TCP | Anywhere | HTTP (ACME challenges + redirect) |
| 443 | TCP | Anywhere | HTTPS |
| 8025 | TCP | Your IP | MailHog UI (optional) |
Step 3: Elastic IP¶
Allocate an Elastic IP and associate it with your instance. This gives a stable address that survives reboots.
Step 4: DNS¶
Create A records pointing to the Elastic IP:
floh-dev.example.com→<ELASTIC_IP>portal.floh-dev.example.com→<ELASTIC_IP>forms.floh-dev.example.com→<ELASTIC_IP>(form-builder SPA — must be a distinct origin from the admin domain)
Caddy will auto-provision Let's Encrypt certificates once DNS resolves.
Step 5: Install Docker and Dependencies¶
ssh -i your-key.pem ubuntu@<ELASTIC_IP>
curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker ubuntu
newgrp docker
sudo apt-get install -y jq
docker --version
docker compose version
jq --version
Step 6: Register the Self-Hosted Runner¶
The deploy workflow runs on a self-hosted runner with the deploy label. Register the EC2 instance as a GitHub Actions runner:
- Go to Settings → Actions → Runners
- Click New self-hosted runner and follow the install steps for Linux x64
- Configure the runner with the label
deploy - Install and start the runner as a service so it survives reboots:
The workflow authenticates to GHCR automatically via docker/login-action — no manual PAT login required.
Step 7: Configure the GitHub "dev" Environment¶
The deploy workflow pulls all secrets and variables from the GitHub dev environment. Non-sensitive runtime config lives in checked-in public config files (config/public/base.env and config/public/ci.env) and is copied to ~/floh/public/ during each deploy.
In Settings → Environments, create an environment named dev and add:
Secrets¶
| Secret | Value |
|---|---|
DB_PASSWORD |
Strong database password |
REDIS_PASSWORD |
Redis password (leave empty if no auth) |
SMTP_USER |
SMTP username (empty for MailHog) |
SMTP_PASS |
SMTP password (empty for MailHog) |
JWT_SECRET |
64-char hex key (pnpm run generate-key) |
SESSION_SECRET |
64-char hex key (pnpm run generate-key) |
SESSION_ENCRYPTION_KEY |
64-char hex key (pnpm run generate-key) |
OIDC_CLIENT_SECRET |
OIDC provider client secret |
CONNECTOR_ENCRYPTION_KEY |
64-char hex key (pnpm run generate-key) |
CONNECTOR_ENCRYPTION_KEY_PREVIOUS |
Previous key during rotation (empty when not rotating) |
Variables¶
| Variable | Value |
|---|---|
DEPLOY_DOMAIN |
floh.authilize.com |
DEPLOY_PORTAL_DOMAIN |
myfloh.authilize.com |
ACME_EMAIL |
Email for Let's Encrypt expiry warnings (optional) |
The workflow writes these values to ~/floh/.env on every deploy. Manual edits to that file are overwritten.
Repository-level variables¶
In addition to the dev environment vars above, the deploy workflow
reads one repository-level variable (Settings → Variables →
Actions, not the dev environment). Repository-level scope keeps
the matrix build job out of the dev environment so it does not
write dev deployment events on every push or inherit any future
dev protection rules.
| Variable | Value |
|---|---|
DEPLOY_FORM_BUILDER_DOMAIN |
forms.authilize.com — required. Must resolve to a distinct origin from DEPLOY_DOMAIN. Baked into the web image as environment.formBuilderEmbedUrl. |
config/public/ci.env contains non-secret URL and OIDC settings:
FRONTEND_URL,PORTAL_FRONTEND_URL,ALLOWED_PORTAL_ORIGINSOIDC_ISSUER,OIDC_CLIENT_ID,OIDC_REDIRECT_URIOIDC_CLAIM_UPSTREAM_ISSUER,OIDC_CLAIM_UPSTREAM_ID— claim names the IdP uses for upstream (federated) identity. For Authifi these areidentityIssuerandemail.CADDY_ADMIN_URL— enables automated TLS cert recovery via Caddy's admin API
Config precedence: When public config files are loaded (
APP_ENVis set andPUBLIC_CONFIG_DIRpoints to the config directory), the server reads non-secret settings only frombase.env/ci.env. Values inprocess.env(including those from Docker Composeenv_file) are not consulted for non-secret keys unlessALLOW_LEGACY_ENV_NON_SECRET=trueis set, which re-enablesprocess.envas a fallback for migration or back-compat. SeereadNonSecretinpackages/server/src/config/index.tsfor the implementation. In normal operation, all non-secret OIDC and runtime settings should go in the public config files.
TRUST_PROXY (in config/public/base.env) should only be enabled when the proxy layer
strips/overwrites untrusted X-Forwarded-* headers. If misconfigured, clients can spoof
source IPs and impact rate-limiting and audit/access-log attribution.
Deploying¶
- Go to Actions → Deploy
- Click Run workflow
- Optionally specify a branch or tag (defaults to
main) - The workflow builds all 6 images on GitHub-hosted runners, pushes to GHCR, then the self-hosted runner pulls the long-running ones, syncs config, and starts services
The build matrix produces:
| Image | Role |
|---|---|
ghcr.io/.../floh/server |
Floh API (Node). |
ghcr.io/.../floh/web |
Floh admin SPA (nginx). FORM_BUILDER_EMBED_URL is baked in at build time so the Workflow Designer's Visual editor toggle is enabled. |
ghcr.io/.../floh/portal-bff |
Self-service portal BFF (Node). |
ghcr.io/.../floh/portal-web |
Self-service portal SPA (nginx). |
ghcr.io/.../floh/form-builder-app |
Standalone Form Builder SPA (nginx). Iframed by the admin SPA from DEPLOY_FORM_BUILDER_DOMAIN. |
ghcr.io/.../floh/mcp |
Floh MCP server (stdio). Pull-only artifact — operators / MCP clients run it via docker run --rm -i …. Not registered as a service in docker-compose.deploy.yml. |
First deploy takes ~5 minutes (image pulls). Subsequent deploys are faster due to layer caching.
Running the MCP server¶
The MCP server speaks stdio, so MCP clients invoke it on demand rather than pinning it to a port. After the deploy publishes the image, an MCP-aware client (e.g. Claude Desktop, Cursor) can pull and run it directly:
docker run --rm -i \
-e FLOH_API_URL="https://floh.authilize.com/api" \
-e OIDC_ISSUER="..." -e OIDC_CLIENT_ID="..." \
-e FLOH_REFRESH_TOKEN="..." \
ghcr.io/axleresearch/floh/mcp:latest
FLOH_API_TOKEN is accepted as a fallback for dev / test only and
is rejected in production deployments. See
packages/mcp/src/index.ts for the full env-var contract.
Operations¶
View logs¶
ssh -i your-key.pem ubuntu@<ELASTIC_IP>
cd ~/floh
docker compose -f docker-compose.deploy.yml logs -f # all services
docker compose -f docker-compose.deploy.yml logs -f server # single service
Restart a service¶
Update secrets or deploy variables¶
Secrets and deploy variables are managed in the GitHub dev environment.
Update values in Settings → Environments → dev and re-run the deploy workflow — the workflow writes ~/floh/.env from environment secrets/vars on every deploy.
Update non-sensitive runtime config¶
Edit config/public/ci.env (or base.env) in the repo, merge to the deploy branch, and re-run the deploy workflow. The workflow copies these files to ~/floh/public/.
Non-secret keys (OIDC settings, URLs, feature flags) should be added to the public config files rather than GitHub environment variables or .env. When public config is loaded, process.env is only consulted as a fallback if ALLOW_LEGACY_ENV_NON_SECRET=true is set (see packages/server/src/config/index.ts). The NON_SECRET_KEYS list in that file defines which keys follow this rule.
Connector key rotation runbook¶
Use this when changing CONNECTOR_ENCRYPTION_KEY without breaking existing connector secrets.
- Generate a new 64-char hex key:
- In the GitHub
devenvironment secrets, set: CONNECTOR_ENCRYPTION_KEY→ the new key-
CONNECTOR_ENCRYPTION_KEY_PREVIOUS→ the old key -
Run the deploy workflow to apply the change.
-
Rotate connector secrets:
- UI: Connectors -> Rotate Keys
- API:
curl -X POST https://<DEPLOY_DOMAIN>/api/connectors/rotate-keys \
-H "Authorization: Bearer <admin-token>"
-
Verify the summary response has
failed: []. -
Clear
CONNECTOR_ENCRYPTION_KEY_PREVIOUSfrom thedevenvironment secrets and re-deploy.
Check service status¶
Access MailHog¶
Open http://<ELASTIC_IP>:8025 in your browser (if port 8025 is open in the security group).
Database access¶
Changing Domains Later¶
- Update DNS A records for the new domain
- Update
DEPLOY_DOMAINand/orDEPLOY_PORTAL_DOMAINin the GitHubdevenvironment variables, and/orDEPLOY_FORM_BUILDER_DOMAINin the repository-level variables (Settings → Variables → Actions) - Update
FRONTEND_URL,PORTAL_FRONTEND_URL,OIDC_REDIRECT_URI, andALLOWED_PORTAL_ORIGINSinconfig/public/ci.envand merge - Run the deploy workflow
- Caddy auto-provisions new Let's Encrypt certificates
Changing
DEPLOY_FORM_BUILDER_DOMAINrequires a freshwebimage build because the embed URL is baked in at build time. The deploy workflow rebuilds on every run, so re-running the workflow is sufficient.
Cost Estimate¶
| Resource | Monthly Cost |
|---|---|
| EC2 t3.medium | ~$33 |
| EBS 30 GB gp3 | ~$2.50 |
| Elastic IP (attached) | Free |
| Data transfer | ~$1-5 |
| GHCR | Free (included in GitHub plan) |
| Total | ~$37-41 |