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.
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.shandfind-setup-token.shwritten 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
BackupVaultNameblank. The stack creates the sharedwi-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 tagwi-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_KEYencrypts every provider key your company stores. See Master key custody.WI_SESSION_SECRETsigns 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#
-
Decide your origin. With a domain it is
https://<your-domain>. Without one it ishttp://<PublicIp>using the stack'sPublicIpoutput, but browsers do not keep a sign-in made over plain HTTP, so use a domain with HTTPS for real use. -
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.shLeave out
WI_DOMAINwhen you have no domain.install.shappendsWI_ALLOWED_ORIGINandWI_DOMAINto the existing.envif it has no such lines, and never changes a line that is there. It also addsWI_DOKKU_SSH_HOST, the hostname Builders use forgit push, taken fromWI_DOMAIN, otherwise from the host part of the origin (set theWI_DOKKU_SSH_HOSTenvironment variable to use something else). You do not setWI_API_BASE_URL: the compose file derives it fromWI_ALLOWED_ORIGIN. What each value means is in Configuration reference.The script logs in to
ghcr.iowithsudo docker login, pulls thewi-serverimage, extractsdocker-compose.prod.ymlfrom it into/opt/wi, and starts the stack under the Compose project namewi-prod. Theappcontainer applies database migrations and never seeds data, so the database starts empty. -
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
SshCidrto 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 composereports that port 22 is already allocated, setWI_DOKKU_SSH_PORTin.envto 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.