AWS
elements man deploy/awsFrom "help me deploy my app" to a running site on AWS, with the agent doing the work and the user answering two questions.
The user does not have to know AWS. The agent recommends it, walks them
through sign-in, asks prototype or production and small or large, gets the
price from AWS, provisions, writes config.jsoc, and deploys. Every command
here is the AWS CLI; nothing is clicked in the console after sign-in. The
shape is fixed on purpose: one Ubuntu box, a managed Postgres for production,
nothing else. No container services, no serverless, no AWS agent toolkits.
Each step from 4 on costs money or changes infrastructure. The two confirmations below are where the user says yes.
1. Account and Sign-In
Ask whether they have an AWS account. If not, open
https://portal.aws.amazon.com/billing/signup in the browser and wait while they sign up (a
card and a phone are needed). Then install the CLI and sign in. Sign-in is a
browser flow; the agent never asks for access keys and none are written
anywhere.
Terminalaws --version || curl -fsSL 'https://awscli.amazonaws.com/v2/install.sh' | bash aws login --profile elements # opens the browser; wait for it to exit aws sts get-caller-identity --profile elements
aws login keeps temporary credentials that refresh on their own for weeks.
When they lapse, run it again. Use the account's region for everything that
follows; an account created through the newer sign-up has one region, and the
agent reads it from the profile rather than asking.
Terminalexport AWS_PROFILE=elements aws configure get region || aws configure set region us-east-1
2. Confirm the Shape
One message, one answer. Prototype or production, small or large, the region, and the address, as in Sizing (opens in a new tab):
Prototype: one small machine with Postgres on it. Production: the same machine plus a managed Postgres with daily backups. I recommend production for anything with customers in it, and small to start. It will answer at
myapp.elements.onlineunless you have a domain you want to use. Which?
A user who names instance types gets them. Everyone else gets the tier's
shape: t4g.micro (small) or t4g.medium (large) on Ubuntu 24.04 arm64,
gp3 disk, standard CPU credits so the machine's price cannot move;
db.t4g.micro for the database, single-AZ on small and Multi-AZ on large.
3. Get the Price and Confirm
Never state a price from memory or from this page. Ask AWS for the chosen shape in the chosen region, then show the hourly rates as AWS states them, the monthly figure at 730 hours, and what is not in it: egress past the included 100 GB a month, storage growth, and the public IPv4 address.
Terminalaws pricing get-products --region us-east-1 --service-code AmazonEC2 \ --filters Type=TERM_MATCH,Field=instanceType,Value=t4g.micro \ Type=TERM_MATCH,Field=regionCode,Value=$(aws configure get region) \ Type=TERM_MATCH,Field=operatingSystem,Value=Linux \ Type=TERM_MATCH,Field=tenancy,Value=Shared \ Type=TERM_MATCH,Field=preInstalledSw,Value=NA \ Type=TERM_MATCH,Field=capacitystatus,Value=Used \ --query 'PriceList[0]' --output text | jq -r '.terms.OnDemand[].priceDimensions[].pricePerUnit.USD' aws pricing get-products --region us-east-1 --service-code AmazonRDS \ --filters Type=TERM_MATCH,Field=instanceType,Value=db.t4g.micro \ Type=TERM_MATCH,Field=regionCode,Value=$(aws configure get region) \ Type=TERM_MATCH,Field=databaseEngine,Value=PostgreSQL \ Type=TERM_MATCH,Field=deploymentOption,Value=Single-AZ \ --query 'PriceList[0]' --output text | jq -r '.terms.OnDemand[].priceDimensions[].pricePerUnit.USD'
The pricing API answers only from us-east-1 or ap-south-1, whatever
region is being priced. Add the gp3 rate (service code AmazonEC2, product
family Storage, volume API name gp3) for the disk and the database
storage. If the call is refused, open https://calculator.aws in the browser
instead; still no number from memory.
Then: "That comes to about $N a month at these rates. Continue?" Nothing is created before the yes.
4. Key and Firewall
A key per project, named by the project id, so apps on one laptop never
share one. AWS makes the key; the private half lands in ~/.ssh and nothing
is imported or typed.
TerminalAPP=$(cat .elements/id) aws ec2 create-key-pair --key-name "elements-$APP" --key-type ed25519 \ --query KeyMaterial --output text > ~/.ssh/elements-$APP.pem chmod 600 ~/.ssh/elements-$APP.pem VPC=$(aws ec2 describe-vpcs --filters Name=is-default,Values=true --query 'Vpcs[0].VpcId' --output text) SG=$(aws ec2 create-security-group --group-name "elements-$APP-app" --description "elements app" --vpc-id $VPC --query GroupId --output text) for p in 22 80 443; do aws ec2 authorize-security-group-ingress --group-id $SG --protocol tcp --port $p --cidr 0.0.0.0/0; done
Production adds a group for the database that only the app's group can reach:
TerminalDBSG=$(aws ec2 create-security-group --group-name "elements-$APP-db" --description "elements db" --vpc-id $VPC --query GroupId --output text) aws ec2 authorize-security-group-ingress --group-id $DBSG --protocol tcp --port 5432 --source-group $SG
5. The Machine
TerminalAMI=$(aws ssm get-parameters --names /aws/service/canonical/ubuntu/server/24.04/stable/current/arm64/hvm/ebs-gp3/ami-id --query 'Parameters[0].Value' --output text) ID=$(aws ec2 run-instances --image-id $AMI --instance-type t4g.micro \ --key-name "elements-$APP" --security-group-ids $SG \ --credit-specification CpuCredits=standard \ --block-device-mappings '[{"DeviceName":"/dev/sda1","Ebs":{"VolumeSize":20,"VolumeType":"gp3"}}]' \ --tag-specifications "ResourceType=instance,Tags=[{Key=Name,Value=$APP-production0},{Key=elements:app,Value=$APP},{Key=elements:env,Value=production},{Key=elements:machine,Value=production0}]" \ --query 'Instances[0].InstanceId' --output text) aws ec2 wait instance-running --instance-ids $ID EIP=$(aws ec2 allocate-address --query AllocationId --output text) aws ec2 associate-address --instance-id $ID --allocation-id $EIP aws ec2 describe-instances --instance-ids $ID --query 'Reservations[0].Instances[0].[PublicIpAddress,PrivateIpAddress]' --output text
Large is t4g.medium and VolumeSize 40. The tags are what make teardown
one filtered command and let several apps share an account. The Elastic IP
is what lets the machine be replaced without changing DNS.
6. The Database (Production)
TerminalPASS=$(openssl rand -base64 24 | tr -d '/+=' | cut -c1-24) aws rds create-db-instance --db-instance-identifier "elements-$APP" \ --engine postgres --engine-version 17 --db-instance-class db.t4g.micro \ --allocated-storage 20 --max-allocated-storage 100 --storage-type gp3 \ --backup-retention-period 7 --no-publicly-accessible \ --vpc-security-group-ids $DBSG --master-username elements --master-user-password "$PASS" \ --tags "Key=elements:app,Value=$APP" "Key=elements:env,Value=production" mkdir -p config/certs && curl -fsSL -o config/certs/db-ca.pem "https://truststore.pki.rds.amazonaws.com/$(aws configure get region)/$(aws configure get region)-bundle.pem" aws rds wait db-instance-available --db-instance-identifier "elements-$APP" aws rds describe-db-instances --db-instance-identifier "elements-$APP" --query 'DBInstances[0].Endpoint.Address' --output text
Large adds --multi-az. The instance takes five to ten minutes; the agent
says so and writes the config meanwhile. Not publicly accessible means the
database has no public address and only the app machine reaches it, which is
right. The laptop reaches it through the machine, which is what
elements db -remote already does.
7. Budget Alerts
So a bad month is noticed on its second day, not on the invoice. Two alerts,
at a monthly figure above the estimate and at double it, to the account's
email. aws budgets create-budget takes the budget and its notifications as
JSON; write them from the estimate in step 3.
8. Config and the First Deploy
config.jsocdeploy: { ssh: { user: "ubuntu", identityFile: "~/.ssh/elements-<project-id>.pem" }, env: { production: { machines: [ { name: "production0", publicIp: "<public ip>", privateIp: "<private ip>" }, ], }, }, }
A domain of their own goes in deploy.domain with an A record at the
registrar pointing at the Elastic IP. Otherwise elements domain claims the
elements.online name and writes it there; see Domain (opens in a new tab).
Production's config/env/production.env points at the database. The file is
gitignored and deploys with the project.
config/env/production.envDB_HOST=<endpoint address> DB_PORT=5432 DB_USER=elements DB_PASSWORD=<the password from step 6> DB_SSL_ROOT_CERT=config/certs/db-ca.pem
A prototype keeps the scaffolded DB_HOST=127.0.0.1.
Terminalelements deploy -json
The first deploy provisions the machine and takes a few minutes; every deploy after lands in under a second. No errors in the report is a live site: open the URL. See Machines (opens in a new tab) for why nothing is verified afterwards, and Operations (opens in a new tab) for moving a prototype's database to RDS later.
Teardown
Everything the agent made carries the elements:app tag. Terminate the
instance, release the address, delete the database (with a final snapshot if
anyone wants the data), then the security groups and the key pair. Confirm
with the user first; this is the one irreversible step.