Raise a server on AWS
$ grim infra:aws-ec2 --dry-run
You have an AWS account and no server yet. grim infra:aws-ec2 is a wizard that makes one: a single Ubuntu machine with an address that does not change, open to the web and closed to everyone else's SSH. When it is done the machine is registered under an alias and grim server:setup can take over without a single extra flag.
It is for the small case on purpose. One instance in the account's default network, three sizes, three disks. A load balancer, a managed database or a private network are outside it.
Usage
$ grim infra:aws-ec2 --dry-run # answer the questions, see the plan and the estimate, create nothing
$ grim infra:aws-ec2 # the full wizard
$ grim infra:aws-ec2 --no-setup # create the machine, leave server:setup for laterRun it from anywhere; it does not read a project. It needs the aws CLI and terraform on your machine and working AWS credentials. grim infra:doctor checks all three and walks you through what is missing.
Arguments and options
Provision a minimal AWS EC2 + Elastic IP server, ready for grim server:setup
Usage
grim infra:aws-ec2 [--alias ALIAS] [--region REGION] [--size SIZE] [--disk DISK] [--ssh-key SSH-KEY] [--ssh-cidr SSH-CIDR] [--email EMAIL] [--arch ARCH] [--profile PROFILE] [--no-setup] [--dry-run] [--force]Options
| Option | Description |
|---|---|
--alias=ALIAS |
Server alias (e.g. aws-prod-1) |
--region=REGION |
AWS region (e.g. eu-central-1) |
--size=SIZE |
Instance size: small | medium | large |
--disk=DISK |
Root disk size in GB (30 | 50 | 100) |
--ssh-key=SSH-KEY |
Path to an existing OpenSSH public key (.pub) |
--ssh-cidr=SSH-CIDR |
CIDR allowed to reach SSH (default: your public IP /32) |
--email=EMAIL |
Let's Encrypt email (passed to grim server:setup) |
--arch=ARCH |
CPU architecture: arm64 | amd64 (default: auto-detect) |
--profile=PROFILE |
AWS CLI profile to use |
--no-setup |
Do not auto-chain into grim server:setup after provisioning |
--dry-run |
Resolve config + show plan and cost only, then exit without creating any AWS resources |
--force |
Allow re-using an existing state directory for the alias |
What it actually does
Five numbered steps, printed as Step 1/5 to Step 5/5. Nothing exists in AWS before step 4.
- Shows the AWS identity. Account number, account alias, the ARN you act as, and the profile. You confirm that this is the account to bill, and the default answer is no. Decline and it offers to switch to another local profile, or to set up a new one by pasting an access key, then shows the identity again.
- Asks for the configuration. Alias, region, size, root disk, SSH key, who may reach SSH, and the email for Let's Encrypt. Every answer has a flag, so a run with all flags and
--no-interactionasks nothing. The region must have a default VPC, and the alias must not already have a state directory. - Prints the plan and a monthly estimate. Instance, disk, Elastic IP and an allowance for outgoing traffic, priced from a snapshot built into grim and dated in the output. With
--dry-runit stops here. - Runs Terraform. After one more confirmation, again defaulting to no, it writes the Terraform files into
~/.grim/infra/<alias>/and runsterraform initandterraform applythere. - Registers the server and hands off. The entry goes into
~/.grim/servers.json, a summary and the two DNS records to create are printed, and it asks whether to rungrim server:setupnow. Say yes and it waits up to four minutes for SSH to answer, then runs setup with the address, alias, login and email already filled in.
What exists in AWS afterwards
| Resource | Details |
|---|---|
| EC2 instance | Ubuntu 24.04 LTS, the newest image Canonical publishes for the architecture. Root disk is encrypted gp3 of 30, 50 or 100 GB. Instance metadata requires IMDSv2. |
| Elastic IP | Attached to the instance. This is the address you point DNS at, and it survives a reboot or a stop. |
| Security group | Port 22 from the range you chose, ports 80 and 443 from anywhere, all outgoing traffic. |
| Key pair | Your public key, imported under the name grim-<alias>. |
All of it is named grim-<alias> and tagged ManagedBy=grim-cli and GrimAlias=<alias>, so you can find it in the console without grim.
Everything in that table is billed by AWS from the moment it exists until you run grim infra:destroy. The estimate in step 3 is the only price grim shows; the real bill follows usage.
Size and architecture
| Size | arm64 | amd64 | vCPU | RAM |
|---|---|---|---|---|
small |
t4g.small | t3.small | 2 | 2 GB |
medium |
t4g.medium | t3.medium | 2 | 4 GB |
large |
t4g.large | t3.large | 2 | 8 GB |
Without --arch the wizard looks up which architectures the base PHP image on GHCR is built for. When both are there it takes arm64, because the t4g instances are the cheaper ones. When only amd64 is there it takes amd64. When the lookup fails it asks, and in a run without interaction it falls back to amd64.
What it records on your machine
~/.grim/infra/<alias>/ holds main.tf, variables.tf, outputs.tf, versions.tf, terraform.tfvars and, after the apply, terraform.tfstate. If you chose to generate a key, id_ed25519 and id_ed25519.pub are there too. That directory is the only record Terraform has of what it made.
~/.grim/servers.json gets an entry keyed by the public IP: the alias, user: deploy, root_user: ubuntu, the Let's Encrypt email, and an infra block with the region, instance ID and type, architecture, disk size, AWS profile, account number and the state directory. root_user is the login the Ubuntu image accepts. grim server:setup and grim server:dev:setup read it from there, which is why neither needs --user for a machine made this way.
Keep ~/.grim/infra/. Lose the state directory and grim can no longer remove the machine for you, while AWS keeps billing it. A generated private key exists only in that directory as well.
Pitfalls
A run without interaction opens SSH to the world
The help text says --ssh-cidr defaults to your public IP. That is true of the wizard, where "Only my IP" is the preselected answer. With --no-interaction and no --ssh-cidr the rule is 0.0.0.0/0. In a scripted run, pass --ssh-cidr=203.0.113.10/32 yourself.
Your IP changes and SSH stops answering
The rule holds the address you had on the day you ran the wizard. From a new network the connection times out, and nothing on the server is wrong. Update the inbound rule of the security group grim-<alias> in the AWS console.
The region has no default VPC
Region eu-north-1 has no default VPC on this account. stops the wizard in step 2, before anything is created. The machine is placed in the default network and grim does not build one. Run the aws ec2 create-default-vpc line it prints, or pick another region.
The alias is taken
State dir for "vps1" already exists. Choose another or destroy first. means ~/.grim/infra/vps1/ is there. A run that failed halfway leaves one, and so does a dry run in which you chose to generate a key, because the key is written in step 2. If the directory has no terraform.tfstate, nothing was created and you can delete it by hand. Otherwise clean up with grim infra:destroy vps1 first. --force reuses the directory and is only right when you know what its state file holds.
Provisioning failed in the middle
Provisioning failed: is followed by Terraform's own error and the path of the state directory. Whatever was created before the failure is still in AWS and still billed, and no entry was written to servers.json. Remove it with grim infra:destroy <alias> --profile=<profile>.
After it finishes
If you let it run grim server:setup, the server is ready for a project. Otherwise the last lines print the exact setup command to run later. Then give the machine a name and someone to watch it.
$ grim infra:dns example.com --alias=vps1
$ grim infra:aws-alarms --instance=vps1 --email=ops@example.comgrim infra:dns prints the records for your DNS provider, and grim infra:aws-alarms mails you when the machine is down. The way from here to a deployed project is the recipe First deploy to a fresh VPS.
Related spells
grim infra:aws-alarms
Set a watch on a server
Attach two CloudWatch alarms to an EC2 instance, for sustained high CPU and for failed status checks, and have AWS mail you when either fires.
grim infra:aws-budget
Set a spending alarm
Create a monthly AWS budget that mails you at 50, 80 and 100 percent of a limit, and when AWS forecasts the month will end above it.
grim infra:dns
Point a domain at a server
Print the two DNS records that point a domain at a server, with step-by-step instructions for Cloudflare or Route 53, and check whether they are live yet.
grim infra:destroy
Unmake what grim made
Remove a server or AWS resource that grim created, after checking the account and making you type its name, and stop it from being billed.
grim server:setup
Raise a production server
Turn a bare Ubuntu or Debian VPS into a host for deployed projects, with Docker, MySQL, nginx, Traefik and a deploy user.