Install on AWS with CloudFormation

Provision a Workforce Intelligence box with the CloudFormation template: parameters, access over Session Manager, outputs, generated secrets, and data protection.

For: Administrators deploying to AWS · Last updated

The template deploy/cloudformation/ec2.yaml provisions the EC2 instance, its role, its security group, the data protections described below, and installs Docker. It does everything that does not need a secret that only you know. You then connect to the box and run install.sh with your registry credentials.

Read Deployment requirements first.

What the template creates#

  • An EC2 instance with an instance role that grants SSM Session Manager access. You do not need an SSH key.
  • A security group allowing ports 80 and 443, plus port 22 only if you set SshCidr. Nothing else is opened (see "What the box exposes" below).
  • Docker and the Compose plugin, installed on first boot. The Compose plugin binary is pinned to an exact release and checksum-verified.
  • install.sh and find-setup-token.sh written to /opt/wi/. You never copy these by hand on an AWS box.
  • /opt/wi/.env, generated once on first boot (see below).
  • Data protection for the root volume: termination protection, a root volume that survives termination, and daily AWS Backup snapshots. See Backups and recovery on AWS.
  • Optionally an Elastic IP and a Route 53 record.

The instance enforces IMDSv2 only.

Parameters#

Parameter Default Meaning
VpcId required VPC to launch into.
SubnetId required Subnet to launch into. It must belong to VpcId and must be a public subnet (see below).
AssociatePublicIp true true assigns a public IP on the instance's network interface. false is for a private-subnet deploy reachable only through Session Manager port forwarding or an internal load balancer you build yourself.
AllocateElasticIp false true also allocates an Elastic IP that survives stop and start. Elastic IPs are an account-wide quota (5 per region by default), so check headroom first.
InstanceType t3.large Instance size. The box runs Postgres, the app, the web server, Dokku, and every company's Coworker workbench container.
RootVolumeSizeGiB 40 (minimum 20) Root volume size. Docker images and workbench containers live here.
HttpCidr 0.0.0.0/0 CIDR allowed to reach port 80.
HttpsCidr 0.0.0.0/0 CIDR allowed to reach port 443.
SshCidr blank CIDR allowed to reach port 22. Blank disables SSH entirely and relies on Session Manager.
KeyName blank EC2 key pair name, used only when SshCidr is set.
NamePrefix wi Prefix for resource Name tags, for example wi-acme.
LatestAmiId a pinned Amazon Linux 2023 x86_64 AMI for us-east-1 A literal AMI id that is never looked up automatically. In another region, pass that region's AMI id. Changing it replaces the instance.
DomainName blank Creates a Route 53 A record. Requires HostedZoneId and AllocateElasticIp=true. Does nothing else.
HostedZoneId blank Route 53 hosted zone for DomainName. Both must be set together.
SnapshotId blank Recovery only. Builds the root volume from this EBS snapshot. See Backups and recovery on AWS.
BackupVaultName blank Blank on the first deployment in an account and region; the shared vault name on every later one.
BackupRetentionDays 14 (minimum 1) Used only by the primary backup stack.

To find the current AMI id for another region, run:

aws ssm get-parameter \
  --name /aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-x86_64 \
  --query Parameter.Value --output text

Use a genuinely public subnet#

SubnetId must be a subnet whose route table sends 0.0.0.0/0 to an Internet Gateway, not to a NAT gateway. An Elastic IP attached to an instance in a NAT-routed subnet is unreachable from the internet whatever the security group allows. Check with:

aws ec2 describe-route-tables \
  --filters "Name=association.subnet-id,Values=<SubnetId>" \
  --query 'RouteTables[0].Routes'

You want to see a route with GatewayId: igw-..., not NatGatewayId.

Backup vault on the first and later deployments#

All Workforce Intelligence deployments in one AWS account and region share one backup vault and one daily plan. Check which case you are in with aws backup list-backup-vaults.

  • First deployment: leave BackupVaultName blank. The stack creates the shared wi-backup-vault, the plan, the selection, and the service role.
  • Every later deployment: pass BackupVaultName=wi-backup-vault. The stack creates no backup resources of its own. Its root volume carries the tag wi-backup=true, and the shared plan picks it up.

Do not delete the first (primary) stack while others still exist. That removes the shared plan and stops backups for all of them. The vault and the existing backups are kept.

Deploy the stack#

aws cloudformation deploy \
  --template-file deploy/cloudformation/ec2.yaml \
  --stack-name wi-<customer-name> \
  --capabilities CAPABILITY_NAMED_IAM \
  --parameter-overrides \
    VpcId=vpc-xxxxxxxx \
    SubnetId=subnet-xxxxxxxx \
    AssociatePublicIp=true \
    NamePrefix=wi-<customer-name> \
    BackupVaultName=wi-backup-vault

Omit BackupVaultName on the first deployment in the region.

Outputs#

Output Meaning
InstanceId The EC2 instance id.
PublicIp The Elastic IP if you allocated one, otherwise the instance's own public IP. Without an Elastic IP this address changes if the instance is stopped and started again (a reboot alone does not change it).
SsmConnectCommand The aws ssm start-session command for this instance.
DnsName The Route 53 record name. Present only when DomainName, HostedZoneId, and AllocateElasticIp=true are all set.
BackupVault The shared vault this deployment's root volume is backed up into.

Connect with Session Manager#

You need the Session Manager plugin for the AWS CLI.

aws ssm start-session --target "$(aws cloudformation describe-stacks \
  --stack-name wi-<customer-name> \
  --query 'Stacks[0].Outputs[?OutputKey==`InstanceId`].OutputValue' --output text)"

Then cd /opt/wi. You find install.sh, find-setup-token.sh, and .env there.

The generated .env#

On first boot the template writes /opt/wi/.env, readable by root only (mode 0600), with two values:

  • WI_MASTER_KEY encrypts every provider key your company stores. See Master key custody.
  • WI_SESSION_SECRET signs sign-in sessions.

The values are generated on the box with openssl rand -base64 32. They never appear in the template, in a stack parameter, or in any AWS API call. Later boots never regenerate them.

The file contains nothing else. install.sh adds the values that describe your deployment (the next section).

Run install.sh#

  1. Decide your origin. With a domain it is https://<your-domain>. Without one it is http://<PublicIp> using the stack's PublicIp output, but browsers do not keep a sign-in made over plain HTTP, so use a domain with HTTPS for real use.

  2. Run the installer with your registry credentials and the origin:

    cd /opt/wi
    WI_ALLOWED_ORIGIN=https://wi.example.com \
    WI_DOMAIN=wi.example.com \
    GHCR_USER=<your-github-username> \
    GHCR_TOKEN=<your-token> \
      ./install.sh
    

    Leave out WI_DOMAIN when you have no domain. install.sh appends WI_ALLOWED_ORIGIN and WI_DOMAIN to the existing .env if it has no such lines, and never changes a line that is there. It also adds WI_DOKKU_SSH_HOST, the hostname Builders use for git push, taken from WI_DOMAIN, otherwise from the host part of the origin (set the WI_DOKKU_SSH_HOST environment variable to use something else). You do not set WI_API_BASE_URL: the compose file derives it from WI_ALLOWED_ORIGIN. What each value means is in Configuration reference.

    The script logs in to ghcr.io with sudo docker login, pulls the wi-server image, extracts docker-compose.prod.yml from it into /opt/wi, and starts the stack under the Compose project name wi-prod. The app container applies database migrations and never seeds data, so the database starts empty.

  3. Open your address in a browser and continue with First-run setup token.

What the box exposes#

The security group allows inbound TCP 80 and 443, and TCP 22 only when you set SshCidr. No other inbound port is open.

Builders push Apps with git push to the Dokku container, which publishes the box's port WI_DOKKU_SSH_PORT (default 22). For a push to reach it from outside the VPC:

  • set SshCidr to a CIDR that contains the Builders' network, and
  • make sure port 22 on the host is free for Dokku. Amazon Linux's own SSH server normally uses it. If docker compose reports that port 22 is already allocated, set WI_DOKKU_SSH_PORT in .env to a free port and add a matching inbound rule to the security group yourself, because the template opens only 22.

The application's own calls to Dokku stay on the internal Docker network and need no inbound port.

DomainName and the instance#

The DomainName and HostedZoneId parameters create the DNS record and nothing else. They never change the instance or /opt/wi/.env, so updating them can never interrupt or replace the box. Whenever you set or change the domain, update WI_DOMAIN, WI_ALLOWED_ORIGIN, and WI_DOKKU_SSH_HOST in .env yourself, then run sudo docker compose -f docker-compose.prod.yml up -d in /opt/wi. See Domains and HTTPS.

Upgrading an existing box and the template#

The template writes install.sh only when an instance first boots. To move an installed box to a release that changes the stack, copy the current install.sh to /opt/wi and re-run it (see Upgrade the stack by re-running install.sh). Updating the CloudFormation stack is not needed for that. Updating it with a newer template changes the instance's startup script, which CloudFormation applies as a stop and start of the instance (not a replacement; termination protection stays). Without an Elastic IP that stop and start changes the public IP, so set AllocateElasticIp=true first or update WI_ALLOWED_ORIGIN afterwards.

Always read the change set before updating the stack#

Before you update the stack, create a change set and read it:

aws cloudformation create-change-set ...
aws cloudformation describe-change-set ...

If Instance shows Replacement: True, do not execute the change set unless you intend to rebuild the box. Changes to LatestAmiId, SnapshotId, RootVolumeSizeGiB, and the root volume tags each replace the instance.

Termination protection and DeletionPolicy: Retain keep the old instance in place after a replacement, so a replacement leaves a new, empty box next to the old one with your data. The Elastic IP and DNS move to the new box, so the site looks like a fresh install. If that happens, stop and follow Backups and recovery on AWS instead of starting onboarding again.