Skip to content

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 later

Run 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.

  1. 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.
  2. 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-interaction asks nothing. The region must have a default VPC, and the alias must not already have a state directory.
  3. 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-run it stops here.
  4. Runs Terraform. After one more confirmation, again defaulting to no, it writes the Terraform files into ~/.grim/infra/<alias>/ and runs terraform init and terraform apply there.
  5. 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 run grim server:setup now. 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.com

grim 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.

Recipes that use it