Skip to content

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_FILE for deploy compose. API TLS terminates at Caddy.
  • FLOH_INTERNAL_URL in public config should stay http://server:7070 for 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:2019 in ci.env to 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 .pem file)

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:

  1. Go to Settings → Actions → Runners
  2. Click New self-hosted runner and follow the install steps for Linux x64
  3. Configure the runner with the label deploy
  4. Install and start the runner as a service so it survives reboots:
sudo ./svc.sh install
sudo ./svc.sh start

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_ORIGINS
  • OIDC_ISSUER, OIDC_CLIENT_ID, OIDC_REDIRECT_URI
  • OIDC_CLAIM_UPSTREAM_ISSUER, OIDC_CLAIM_UPSTREAM_ID — claim names the IdP uses for upstream (federated) identity. For Authifi these are identityIssuer and email.
  • CADDY_ADMIN_URL — enables automated TLS cert recovery via Caddy's admin API

Config precedence: When public config files are loaded (APP_ENV is set and PUBLIC_CONFIG_DIR points to the config directory), the server reads non-secret settings only from base.env / ci.env. Values in process.env (including those from Docker Compose env_file) are not consulted for non-secret keys unless ALLOW_LEGACY_ENV_NON_SECRET=true is set, which re-enables process.env as a fallback for migration or back-compat. See readNonSecret in packages/server/src/config/index.ts for 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

  1. Go to Actions → Deploy
  2. Click Run workflow
  3. Optionally specify a branch or tag (defaults to main)
  4. 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

docker compose -f docker-compose.deploy.yml restart server

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.

  1. Generate a new 64-char hex key:
openssl rand -hex 32
  1. In the GitHub dev environment secrets, set:
  2. CONNECTOR_ENCRYPTION_KEY → the new key
  3. CONNECTOR_ENCRYPTION_KEY_PREVIOUS → the old key

  4. Run the deploy workflow to apply the change.

  5. Rotate connector secrets:

  6. UI: Connectors -> Rotate Keys
  7. API:
curl -X POST https://<DEPLOY_DOMAIN>/api/connectors/rotate-keys \
  -H "Authorization: Bearer <admin-token>"
  1. Verify the summary response has failed: [].

  2. Clear CONNECTOR_ENCRYPTION_KEY_PREVIOUS from the dev environment secrets and re-deploy.

Check service status

docker compose -f docker-compose.deploy.yml ps

Access MailHog

Open http://<ELASTIC_IP>:8025 in your browser (if port 8025 is open in the security group).

Database access

docker compose -f docker-compose.deploy.yml exec postgres psql -U floh -d floh

Changing Domains Later

  1. Update DNS A records for the new domain
  2. Update DEPLOY_DOMAIN and/or DEPLOY_PORTAL_DOMAIN in the GitHub dev environment variables, and/or DEPLOY_FORM_BUILDER_DOMAIN in the repository-level variables (Settings → Variables → Actions)
  3. Update FRONTEND_URL, PORTAL_FRONTEND_URL, OIDC_REDIRECT_URI, and ALLOWED_PORTAL_ORIGINS in config/public/ci.env and merge
  4. Run the deploy workflow
  5. Caddy auto-provisions new Let's Encrypt certificates

Changing DEPLOY_FORM_BUILDER_DOMAIN requires a fresh web image 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