Deploying Guidely on AWS

infra/ contains an AWS CDK (v2, TypeScript) app that deploys the whole platform into one CloudFormation stack named Guidely-<envName>:

                     Internet / customer browsers
                                │
                   ┌────────────▼────────────┐
                   │  Application Load       │  public subnets, HTTPS (ACM cert) or HTTP
                   │  Balancer               │  ingress limited to `allowedCidrs`
                   └────────────┬────────────┘
                                │ :3000 (service security group only)
        ┌───────────────────────▼───────────────────────┐
        │  ECS Fargate cluster (Container Insights)      │  private subnets + NAT
        │   web service  ×N  (migrate + Next.js server)  │  same image, different command
        │   worker service ×1 (node apps/web/dist/worker.cjs)
        └───────────────────────┬───────────────────────┘
                                │ :5432 (service security group only)
                   ┌────────────▼────────────┐
                   │  RDS PostgreSQL 16      │  private, encrypted, 7-day backups
                   └─────────────────────────┘
   S3: session recordings bucket (private, SSE-S3, TLS-only, gateway VPC endpoint, retained on teardown)
   Secrets Manager: RDS master credentials · SESSION_SECRET · GitHub and Google OAuth {clientId, clientSecret}
   CloudWatch Logs: /aws/ecs web + worker log groups (30 days) · RDS postgresql log export

The image is built from the repo root with apps/web/Dockerfile during cdk deploy and pushed to the CDK bootstrap ECR repository. The web container's default command runs prisma migrate deploy and then starts the server; the worker runs from the same image with node apps/web/dist/worker.cjs. Redis is not provisioned; the app falls back to in-memory rate limiting and config caching.

Database TLS

RDS PostgreSQL 15+ enforces rds.force_ssl=1. The image bundles the Amazon RDS root CA (/app/rds-ca-bundle.pem) and the entrypoint adds sslmode=require&sslcert=…&sslaccept=strict to the composed DATABASE_URL (Prisma) and exports DATABASE_SSL_CA (worker). Set DB_SSL=false only for databases that do not offer TLS.

Security model

The ALB is public on purpose: customer applications load /sdk/guidely.js and call /api/v1/sdk/* from end users' browsers, authenticated with per-environment public keys and CORS allow-lists. Everything else (the dashboard under /app/* and the management API under /api/v1/*) requires a session cookie, and the stack sets AUTH_SIGNUP_ENABLED=false, so nobody can create an account on their own: people get in through GitHub (members of the organisations in GITHUB_ALLOWED_ORGS, or of organisations a workspace admits), Google (accounts in Google Workspace domains a workspace admits), invitations, or password logins that workspace admins create (passwordLogin, on by default). Each workspace chooses which of these methods open it. Tasks run in private subnets, the database accepts connections only from the ECS security group, and every secret is injected at task start from Secrets Manager (never baked into the image). Session recordings live in a private S3 bucket (all public access blocked, S3-managed encryption, HTTPS-only bucket policy, bucket-owner object ownership) that the tasks reach through an S3 gateway endpoint. The web task role can read, write and delete objects under replays/; the worker role can read and delete (retention). Recordings are never served from S3 directly: the dashboard streams them through the authenticated management API. allowedCidrs can narrow the ALB to office or VPN ranges if the SDK API does not need to be reachable from the open internet.

Prerequisites

  • An AWS account and the [AWS CLI](https://docs.aws.amazon.com/cli/) configured with credentials that can

    create VPC, RDS, ECS, IAM, Secrets Manager, CloudWatch and (optionally) Route53 resources. Export AWS_PROFILE/AWS_REGION or rely on your default profile; the CDK reads the account and region from it (CDK_DEFAULT_ACCOUNT/CDK_DEFAULT_REGION). Nothing is hardcoded.

  • Docker running locally (the image is built on your machine; `linux/amd64` is targeted, so Apple Silicon

    builds cross-compile via BuildKit emulation and take a few minutes).

  • Node 22 and pnpm (corepack enable), then pnpm install at the repo root.
  • CDK bootstrap, once per account/region:
    pnpm --filter @guidelytech/infra bootstrap
  • For HTTPS (required for a usable deployment; see below): a DNS name you control and an ACM certificate

    for it in the same region as the stack. Request it with aws acm request-certificate --domain-name guidely.example.com --validation-method DNS and complete DNS validation, or let the console do it. If the zone is in Route53, note its hosted zone id.

Configuration

Values are read, in order of precedence, from -c key=value flags, the context block of infra/cdk.json, and GUIDELY_<KEY_IN_UPPER_SNAKE_CASE> environment variables (e.g. GUIDELY_GITHUB_ALLOWED_ORGS). Empty strings count as unset.

Context keyDefaultDescription
envNameprodStack name suffix and Environment tag. prod turns on RDS deletion protection and keeps a final snapshot on delete; any other value makes the database deletable.
githubAllowedOrgs– (required)Comma-separated GitHub organisation logins. Each gets a shared workspace the first time one of its members signs in; later, workspace admins choose which organisations their workspace admits.
googleOAuthSecretArn–Reuse an existing secret with JSON keys clientId and clientSecret for the Google OAuth client. When absent the stack creates one with REPLACE_ME placeholders, and Google sign-in stays hidden until you fill it in (step 3).
passwordLogintrueAUTH_PASSWORD_ENABLED. Email/username + password sign-in for invited people and workspace-created logins. false makes GitHub / Google the only way in.
domainName–Public DNS name of the dashboard. Sets APP_URL=https://<domainName>.
certificateArn–ACM certificate ARN for the HTTPS listener. Without it the ALB serves HTTP on port 80 and synth prints a warning.
hostedZoneId, hostedZoneName–Route53 zone for domainName; when both are set an A alias record is created pointing at the ALB.
githubOAuthSecretArn–Reuse an existing Secrets Manager secret with JSON keys clientId and clientSecret. When absent the stack creates one with REPLACE_ME placeholders.
anthropicSecretArn–Existing Secrets Manager secret whose plain-text value is an Anthropic API key, injected as ANTHROPIC_API_KEY as the shared key for every project. Projects can instead add their own key in Scout settings; with neither, the messenger routes every conversation to the Inbox.
smtpSecretArn–Existing secret whose plain-text value is an SMTP_URL (e.g. Amazon SES SMTP credentials) for Inbox notification emails.
emailFrom–EMAIL_FROM for notification emails.
platformAdminEmails–Comma-separated emails of platform admins (PLATFORM_ADMIN_EMAILS), who manage every workspace at /admin. Matched against the verified GitHub email.
dbInstanceClasst4g.smallRDS instance class (without the db. prefix). Graviton classes are cheapest.
webDesiredCount2Web tasks at rest; autoscaling runs between this and 3× this on 60% CPU.
webCpu / webMemory512 / 1024Fargate CPU units / MiB for a web task.
workerCpu / workerMemory256 / 512Fargate CPU units / MiB for the worker task.
eventRetentionDays365Passed through as EVENT_RETENTION_DAYS.
natGateways1NAT gateways (one per AZ for HA; one is enough for most deployments and halves the NAT cost).
allowedCidrs0.0.0.0/0Comma-separated CIDRs allowed to reach the ALB on 80/443.
availabilityZones<region>a,<region>bOverride the two VPC AZs if your account cannot use the defaults.

Fixed environment the stack always sets: NODE_ENV=production, LOG_LEVEL=info, PORT=3000, APP_URL and NEXT_PUBLIC_GUIDELY_API_URL (same value), DB_HOST/DB_PORT/DB_NAME=guidely, GITHUB_ALLOWED_ORGS, AUTH_PASSWORD_ENABLED (from passwordLogin), AUTH_SIGNUP_ENABLED=false, REPLAY_S3_BUCKET (the recordings bucket), REPLAY_S3_REGION, REPLAY_S3_PREFIX=replays (S3 credentials come from the task role). Secrets injected from Secrets Manager: DB_USER and DB_PASSWORD (RDS master secret; the password is generated without URL-unsafe characters so the entrypoint can compose DATABASE_URL), SESSION_SECRET (64 random alphanumerics), GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET, GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET.

Commands (run from the repo root; pass -c flags after the script name):

pnpm --filter @guidelytech/infra synth   -c githubAllowedOrgs=my-org      # render CloudFormation to infra/cdk.out
pnpm --filter @guidelytech/infra diff    -c githubAllowedOrgs=my-org      # compare with what is deployed
pnpm --filter @guidelytech/infra run deploy  -c githubAllowedOrgs=my-org      # build image, push, deploy
pnpm --filter @guidelytech/infra run destroy                                   # tear down (see Teardown)
pnpm --filter @guidelytech/infra test                                      # template assertions, no AWS needed

Rather than repeating flags, put stable values in infra/cdk.json under context (it is committed, so keep secrets out of it; ARNs and org names are fine).

First deployment

1. Create the GitHub OAuth App

In the GitHub organisation (or your user account) go to Settings → Developer settings → OAuth Apps → New OAuth App and fill in:

  • Homepage URL: your APP_URL (e.g. https://guidely.example.com)
  • Authorization callback URL: <APP_URL>/api/v1/auth/github/callback

Copy the client id and generate a client secret. Guidely requests the read:org scope so it can verify membership of the organisations in githubAllowedOrgs. Members whose organisation membership is private must grant the organisation on GitHub's consent screen (the "Organization access" section shown during the first sign-in) or make their membership public; otherwise membership cannot be seen and sign-in is refused. Organisations with OAuth App access restrictions enabled must approve the app.

If you do not know the final URL yet, see "Two-step deploy" below.

Google (optional). In Google Cloud Console → APIs & Services → Credentials → Create credentials → OAuth client ID (application type Web application; configure the OAuth consent screen first) add the authorized redirect URI <APP_URL>/api/v1/auth/google/callback (the GoogleOAuthCallbackUrl output). Guidely asks for openid email profile only. Use an Internal consent screen to limit sign-in to your own Google Workspace, or External to let workspaces admit other domains.

2. Deploy

With a domain and certificate ready:

pnpm --filter @guidelytech/infra run deploy \
  -c githubAllowedOrgs=my-org \
  -c domainName=guidely.example.com \
  -c certificateArn=arn:aws:acm:eu-west-1:123456789012:certificate/... \
  -c hostedZoneId=Z0123456789ABC -c hostedZoneName=example.com    # optional, Route53 only

The first deploy takes 15–25 minutes (RDS is the slow part). The stack prints its outputs at the end; GitHubOAuthCallbackUrl is the exact value to register on the OAuth App, GitHubOAuthSecretArn is the secret to fill next. If the zone is not in Route53, create a CNAME (or ALIAS/ANAME) from domainName to the AlbDnsName output.

3. Fill the GitHub OAuth secret

Unless you passed githubOAuthSecretArn, the stack created a secret with placeholders. Store the real values (the web/worker tasks read the secret at start, so restart them afterwards):

aws secretsmanager put-secret-value \
  --secret-id <GitHubOAuthSecretArn output> \
  --secret-string '{"clientId":"Iv1.abc123","clientSecret":"0123456789abcdef"}'

aws ecs update-service --cluster <ClusterName> --service <WebServiceName> --force-new-deployment
aws ecs update-service --cluster <ClusterName> --service <WorkerServiceName> --force-new-deployment

Fill the Google secret (GoogleOAuthSecretArn output) the same way to turn on Continue with Google. CloudFormation never overwrites the value on later deploys because the template content does not change.

4. Sign in and seed

Open APP_URL and sign in with GitHub (members of githubAllowedOrgs are accepted). Then set up each workspace under Settings → Sign-in (methods, GitHub organisations, Google domains) and Settings → Team (invitations, password logins). To load the demo workspace and sample guides, run the bundled seed (apps/web/dist/seed.cjs) inside a running web task:

TASK=$(aws ecs list-tasks --cluster <ClusterName> --service-name <WebServiceName> --query 'taskArns[0]' --output text)
aws ecs execute-command --cluster <ClusterName> --task "$TASK" --container web --interactive \
  --command "docker-entrypoint.sh seed"

docker-entrypoint.sh seed composes DATABASE_URL from the DB_* variables and then runs node apps/web/dist/seed.cjs; calling the script directly from an exec session would not see the DATABASE_URL that the entrypoint exports for the server process.

aws ecs execute-command … --command "/bin/sh" gives an interactive shell (the ExecShellHint output repeats this). ECS Exec requires the Session Manager plugin for the AWS CLI.

Two-step deploy when the URL is not known in advance

If you want to see the ALB DNS name before choosing a domain:

  1. `pnpm --filter @guidelytech/infra run deploy -c githubAllowedOrgs=my-org` creates everything with an HTTP-only

    listener; APP_URL becomes http://<alb-dns>. Note that production session cookies carry the Secure flag, so sign-in will not complete over plain HTTP; this step is only for obtaining the DNS name and checking /api/health.

  2. Point your domain at the ALB, request/validate the ACM certificate, and create the GitHub OAuth App

    with the final https:// URLs.

  3. Redeploy with `-c domainName=… -c certificateArn=…`. CDK swaps the listener to HTTPS (with an HTTP→HTTPS

    redirect), updates APP_URL in both task definitions and rolls the services.

Passing domainName + certificateArn up front skips the dance entirely.

Day-2 operations

Deploying a new version. Run the same deploy command; CDK rebuilds the image (content-hashed, so an unchanged repo does not redeploy), registers new task definitions and performs a rolling update with minHealthyPercent=100. The deployment circuit breaker rolls back automatically if new tasks fail to become healthy. diff shows the pending changes first.

Migrations run inside the web container at start (prisma migrate deploy), before the server listens; the ALB health-check grace period and the container startPeriod are 120 s to leave room. Keep migrations additive, as docs/deployment.md describes, because old tasks keep serving until new ones are healthy. The worker task sets SKIP_MIGRATIONS=true so only web tasks migrate; if the worker starts before the first web task has finished migrating it simply crashes and ECS restarts it.

Logs. CloudWatch log groups Guidely-<env>-WebLogs… and …WorkerLogs… (30-day retention), JSON lines, stream prefix web/ and worker/. RDS exports its postgresql log to CloudWatch too. Container Insights gives per-service CPU/memory dashboards under CloudWatch → Container Insights. Health: curl https://<APP_URL>/api/health.

Scaling knobs. webDesiredCount, webCpu/webMemory and the worker sizes are context values; the web service autoscales between webDesiredCount and 3× that on 60% average CPU. Bigger databases: change dbInstanceClass (a resize causes a few minutes of downtime; storage autoscales 20→100 GiB). natGateways=2 for an AZ-resilient egress path. Redis is not needed until you run many replicas and want shared rate limits/config cache; add an ElastiCache cluster and REDIS_URL if you do.

Rotating secrets.

  • SESSION_SECRET: aws secretsmanager put-secret-value --secret-id <SessionSecretArn> --secret-string "$(openssl rand -base64 48 | tr -d '/+=')", then force a new deployment of both services. All sessions and authoring tokens are invalidated.
  • GitHub OAuth: generate a new client secret on GitHub, put-secret-value as in step 3, redeploy the services.
  • Database password: aws secretsmanager rotate-secret is not configured (it needs a rotation Lambda). Instead change the password in RDS (aws rds modify-db-instance --master-user-password …), update the password key in the secret, and force a new deployment. Do it in a maintenance window; running tasks keep their pooled connections until restarted.

Backups and restore. Automated RDS backups are kept for 7 days with point-in-time recovery. Take a manual snapshot before risky changes: aws rds create-db-snapshot --db-instance-identifier <id> --db-snapshot-identifier guidely-pre-upgrade. To restore, create a new instance from the snapshot (aws rds restore-db-instance-from-db-snapshot) or to a point in time, then either repoint DB_HOST (edit the task definitions) or, more simply, pg_dump from the restored instance and pg_restore into the live one from a web task shell. Deleting a prod stack leaves a final snapshot because of the Snapshot removal policy.

Costs (us-east-1 list prices, ballpark). Fargate: 2 web tasks at 0.5 vCPU/1 GiB ≈ $36/month, worker 0.25 vCPU/0.5 GiB ≈ $9; RDS db.t4g.small single-AZ ≈ $25 + 20 GiB gp3 ≈ $2.5; ALB ≈ $17 + LCU usage; one NAT gateway ≈ $33 + data processing; Secrets Manager 3 secrets ≈ $1.2; CloudWatch logs/insights a few dollars; S3 for recordings ≈ $0.023/GB-month plus requests (roughly 50–300 KB per recorded minute, kept for each project's replay retention). Expect roughly $125–150/month for the defaults, dominated by NAT and RDS; a dev environment with webDesiredCount=1 and dbInstanceClass=t4g.micro lands near $80.

Teardown

pnpm --filter @guidelytech/infra run destroy -c githubAllowedOrgs=my-org    # plus the same -c flags you deployed with

For envName=prod the database has deletion protection, so the destroy fails until you turn it off (aws rds modify-db-instance --db-instance-identifier <id> --no-deletion-protection); it then keeps a final snapshot you must delete separately if unwanted. Secrets created by the stack are deleted with it. The session recordings bucket is retained (it holds customer data and the stack uses no custom resources to empty it): empty and delete it yourself if unwanted (aws s3 rb s3://<ReplayBucketName output> --force). The ECR images live in the CDK bootstrap repository and are not removed; prune them with aws ecr batch-delete-image or the bootstrap stack's lifecycle rules.