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) and, by default, the minimum Amazon Bedrock permissions described in Instance role, IMDS hop limit and AWS access.
- 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 requires IMDSv2, with a hop limit of 2 by default so containers can use the instance role.
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. |
ImdsHopLimit |
2 (allowed: 1 or 2) |
IMDSv2 hop limit. 2 lets the app and Coworker workbench containers obtain the instance role's credentials; 1 restricts them to the host. See the section below. |
EnableBedrockAccess |
true |
true adds an inline policy to the instance role that allows Amazon Bedrock model invocation and listing. false adds nothing. |
AdditionalInstanceRolePolicyArns |
blank | Comma-separated IAM managed policy ARNs attached to the instance role in addition to the SSM policy. |
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_KEYprotects every credential 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 domain, for example
wi.example.com. Without one, useWI_ALLOWED_ORIGIN=http://<PublicIp>(the stack'sPublicIpoutput) in its place, 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 domain:
cd /opt/wi WI_DOMAIN=wi.example.com \ GHCR_USER=<your-github-username> \ GHCR_TOKEN=<your-token> \ ./install.shinstall.shappendsWI_DOMAINto the existing.envif it has no such line, and never changes a line that is there. That is the only address setting: the web origin, the base URLs for emailed links, webhooks and deployed Apps, and the host Builders use forgit pushare all derived from it. 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.
Instance role, IMDS hop limit and AWS access#
The instance role's temporary credentials are served by the instance metadata service (IMDS)
at 169.254.169.254. The template requires IMDSv2, a token handshake whose reply is limited by
a hop limit. The ImdsHopLimit parameter sets it, and the default is 2.
At a hop limit of 1 only processes on the host itself can read the role's credentials. The app and every Coworker workbench run in Docker containers on a bridge network, one hop further away, so they cannot. At 2 they can. That has these consequences:
- The app container and every Coworker workbench container on the instance can obtain the instance role's credentials.
- This is what makes the Use the server's IAM role option work for an Amazon Bedrock provider. See Use AWS Bedrock.
- It also lets a custom connector's script call any AWS service the role is granted (for example S3), with no stored keys.
- Anything granted to the role is available to code that Coworkers run. It is also reachable by a request-forgery bug in a container. Grant least privilege, and grant nothing to the role that you would not give to the app itself.
What the role grants#
By default the role has the AWS-managed AmazonSSMManagedInstanceCore policy and, because
EnableBedrockAccess is true, an inline policy that allows:
bedrock:InvokeModelandbedrock:InvokeModelWithResponseStreamon foundation models in any region and on your account's inference profiles. A cross-region inference profile (an id startingus.,eu.orglobal.) is also authorized in each destination region, which is why the regions are a wildcard.bedrock:ListFoundationModelsandbedrock:ListInferenceProfileson all resources (AWS does not support narrowing list actions), used to fill the model list.
Set EnableBedrockAccess=false if you use only access keys or a Bedrock API key, or want to
manage Bedrock permissions in your own policy.
Grant the role more access#
Create an IAM managed policy for exactly what a connector needs, then pass its ARN in
AdditionalInstanceRolePolicyArns (comma-separated for several). The ARNs are attached to the
instance role alongside the SSM policy. You do not edit the template.
aws cloudformation deploy \
--template-file deploy/cloudformation/ec2.yaml \
--stack-name wi-<customer-name> \
--capabilities CAPABILITY_NAMED_IAM \
--parameter-overrides \
AdditionalInstanceRolePolicyArns=arn:aws:iam::<account-id>:policy/wi-connector-s3-read
Pass every parameter that you set on the original deploy again, or use
aws cloudformation update-stack --use-previous-template with UsePreviousValue=true for the
rest. Otherwise the stack reverts them to their defaults.
Set the hop limit back to 1#
Set ImdsHopLimit=1 in a stack update. The instance then serves role credentials only to
processes on the host. What stops working:
- The Use the server's IAM role Bedrock option, and any other use of the instance role from inside the app or a workbench container.
- Connector scripts that rely on the role for AWS access.
What keeps working: Bedrock providers configured with Access keys or a Bedrock API key, every other provider, and Session Manager access.
An existing stack adopts it with a stack update#
Update the CloudFormation stack with the current template. The hop limit and the role policy
change in place: the instance is not replaced and not restarted, and the containers need no
restart. Read the change set first as described below. The new parameters default to the values
above, so an update with no parameter changes turns the hop limit to 2 and adds the Bedrock
policy. To keep an existing stack as it was, pass ImdsHopLimit=1 and
EnableBedrockAccess=false in the same update.
A box that is not on EC2 has no instance role. Put AWS_ACCESS_KEY_ID,
AWS_SECRET_ACCESS_KEY and AWS_REGION in /opt/wi/.env and run docker compose up -d in
/opt/wi, or configure the provider with access keys. An in-app update does not re-read
.env. See What an update changes.
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.
Builders push to deployed Apps over port 2222, which the template opens to DokkuSshCidr
(default 0.0.0.0/0; narrow it to restrict who can connect). 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 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_DOMAIN (or 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.