# AWS The agent takes an app from "help me deploy" to a running site on AWS. The user answers two questions: prototype or production, small or large. The user does not have to know AWS. The agent recommends it, walks them through sign-in, asks the two questions, 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: one Ubuntu box, a managed Postgres for production, nothing else. No container services, no serverless, no AWS agent toolkits. Step 3 reads the price list with `jq`; install it if `jq --version` fails. 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. ```bash Terminal aws --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. ```bash Terminal export AWS_PROFILE=elements aws configure get region || aws configure set region us-east-1 ``` ## 2. Confirm the Shape One message, two choices, as in [Sizing](sizing). Nothing else is asked: the region comes from the profile, and the address is a generated name under elements.online from `elements domain`. > Prototype or production? Prototype is one small machine with Postgres on > it. Production is the same machine plus a managed Postgres with daily > backups, and is right for anything with customers in it. > > Small or large? Small is right to start. A user who mentions a domain of their own gets it in `deploy.domain` instead of the generated name. 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. One machine. A second app machine is never part of the first deploy: a prototype has its database on the box, and a production app on a small machine moves to large first. Add a machine when a production app on the large machine is still short of CPU or memory in the provider's metrics, or when the user asks for one. It is the same `run-instances` command named `production1`, a second entry under `machines`, and the same database; see [Operations](operations). ## 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. ```bash Terminal aws 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. ```bash Terminal APP=$(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: ```bash Terminal DBSG=$(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 ```bash Terminal AMI=$(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) ```bash Terminal PASS=$(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 Two alerts to the account's email, one at a monthly figure above the estimate and one at double it, so a bad month is noticed on its second day and not on the invoice. `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 ```jsoc config.jsoc deploy: { ssh: { user: "ubuntu", identityFile: "~/.ssh/elements-.pem" }, env: { production: { machines: [ { name: "production0", publicIp: "", privateIp: "" }, ], }, }, } ``` A domain of their own goes in `deploy.domain` with an A record at the registrar pointing at the Elastic IP. Otherwise `elements domain` gets the generated name and writes it there; see [Domain](domain). Production's `config/env/production.env` points at the database. The file is gitignored and deploys with the project. ```text config/env/production.env DB_HOST= DB_PORT=5432 DB_USER=elements DB_PASSWORD= DB_SSL_ROOT_CERT=config/certs/db-ca.pem ``` A prototype keeps the scaffolded `DB_HOST=127.0.0.1`. ```bash Terminal elements 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](machines) for why nothing is verified afterwards, and [Operations](operations) for moving a prototype's database to RDS later. ## Teardown Everything the agent made carries the `elements:app` tag. Confirm with the user first; this is the one irreversible step. Then, in this order, since the security groups cannot go while the instance and database use them: ```bash Terminal APP=$(cat .elements/id) ID=$(aws ec2 describe-instances --filters "Name=tag:elements:app,Values=$APP" "Name=instance-state-name,Values=running,stopped" --query 'Reservations[].Instances[].InstanceId' --output text) aws ec2 terminate-instances --instance-ids $ID && aws ec2 wait instance-terminated --instance-ids $ID for a in $(aws ec2 describe-addresses --query "Addresses[?AssociationId==null].AllocationId" --output text); do aws ec2 release-address --allocation-id $a; done aws rds delete-db-instance --db-instance-identifier "elements-$APP" --final-db-snapshot-identifier "elements-$APP-final" # or --skip-final-snapshot aws rds wait db-instance-deleted --db-instance-identifier "elements-$APP" for g in "elements-$APP-db" "elements-$APP-app"; do aws ec2 delete-security-group --group-name $g; done aws ec2 delete-key-pair --key-name "elements-$APP" && rm -f ~/.ssh/elements-$APP.pem ``` A prototype has no database, so its `rds` lines fail harmlessly.