Orbit: Deployment Plan

First production deploy of Orbit at https://orbit.singlebit.xyz, written 28 October 2026 against main. Each step says who does it and exactly what to click or paste. The day-to-day runbook (releases, backups, restores, moving hosts) is deploy/README.md; the decision record is DECISIONS.md ADR-015.

Who's who in this doc. You = Adarsh, in the AWS console, Resend, GitHub and the server. Domain admin = whoever manages singlebit.xyz on Vercel. Claude = code changes, if any step needs one.


1. The short version

#DecisionChoiceStatus
1Where it runsA new, separate stack orbit-prod in the AWS account Thnkrr uses. Nothing shared with ThnkrrDecided
2RegionUS East (N. Virginia), us-east-1Decided
3The serverEC2 t4g.micro (2 processors, 1 GB memory + 1 GB swap), Ubuntu 24.04 ARM, 20 GB diskDecided
4The databasePostgreSQL 16 on the same server, nightly backups to S3 and daily disk snapshotsDecided
5Web app and HTTPSCaddy on the server, one address for the app and its API, free certificateDecided
6Addressorbit.singlebit.xyz, DNS on VercelDecided
7EmailResend, through an adapter: any SMTP provider is a settings changeDecided
8Lock-inNone by design: only the AWS template is AWS-specific; the rest runs on any Ubuntu serverDecided
9Getting code onA read-only GitHub deploy key; sudo orbit-deploy over Session Manager. No SSH, no port 22Decided

Money: about $12 a month (§6), from the same account and credits as Thnkrr.

Measured, not guessed: on a 1 GB + 1 GB swap VM with 10,000 tasks, Orbit idles at 481 MB, five people clicking non-stop get pages in 0.19 s (median), and PDFs take ~0.1 s. The whole setup was run end to end on a fresh 1 GB VM: install, first deploy, workspace, update, backup, restore, reboot (deploy/README.md, "Size and cost").


2. Where we are, and your steps

Ready?
The codeYes. Everything is on main; pnpm ci:local passes
The deploy kitYes. deploy/ (scripts and the AWS template), tested on a VM
Going livePhases A and B below, about 1½ hours of clicking plus waiting for DNS

Tick each step when it's done. Details for every step are in §3.

Phase A: before AWS (about 30 minutes, plus the domain admin)

Phase B: AWS and the server (about 1 hour)

Phase C: the first week


3. Every step, in detail

A3. Resend

  1. Sign up at resend.com with the team's address (singlebit.team@gmail.com).
  2. Domains → Add domain → orbit.singlebit.xyz, region North Virginia (us-east-1). Resend lists 3–4 DNS records. Send them to the domain admin (A4); leave the page open.
  3. API Keys → Create API key: name orbit-prod, permission Sending access, domain orbit.singlebit.xyz. Copy the key (re_…) into the password-manager entry now; Resend shows it only once.

The free plan sends 100 emails a day. For ~15 people (sign-in links, assignments, mentions, approvals, overdue tasks) that's enough to start; Pro is $20 a month for 50,000.

A4. DNS records for email (domain admin, on Vercel)

Vercel → the team that owns singlebit.xyz → Domains → singlebit.xyz → DNS Records → Add. Names are relative to singlebit.xyz, so send.orbit means send.orbit.singlebit.xyz.

NameTypeValue
resend._domainkey.orbitTXTthe long p=MIGf… value Resend shows (DKIM)
send.orbitMXfeedback-smtp.us-east-1.amazonses.com, priority 10
send.orbitTXTv=spf1 include:amazonses.com ~all

Copy Resend's values exactly; if they differ from this table, Resend's are right. No DMARC record is needed: singlebit.xyz's own (v=DMARC1; p=none) already covers orbit.

Checked 28 October against Vercel's nameservers: nothing needs removing or changing. orbit has no records of its own yet; the *.singlebit.xyz wildcard (Vercel) answers for it and shows DEPLOYMENT_NOT_FOUND, so no Vercel project uses it. The domain's CAA record allows Let's Encrypt, which the server uses for its certificate. The domain's Google Workspace mail (MX and SPF on singlebit.xyz) is untouched, because Resend sends from orbit.singlebit.xyz and send.orbit. Then in Resend press Verify. It can take a few minutes to an hour.

A5. Seeing Orbit's cost separately (optional)

Billing → Cost allocation tags → find Project → Activate. Everything in the stack is tagged Project=orbit, so after a day Cost Explorer can group by it. This is an account-wide billing setting, so check with the senior first. It changes nothing for Thnkrr.

B2. Create the stack (CloudShell)

  1. AWS console, region US East (N. Virginia) (top right) → the CloudShell icon (top bar).

  2. CloudShell → Actions → Upload file → ~/Documents/orbit/deploy/aws/orbit.yml.

  3. Paste:

    AMI=$(aws ssm get-parameter \
      --name /aws/service/canonical/ubuntu/server/24.04/stable/current/arm64/hvm/ebs-gp3/ami-id \
      --query Parameter.Value --output text)
    aws cloudformation deploy --stack-name orbit-prod --template-file orbit.yml \
      --capabilities CAPABILITY_NAMED_IAM \
      --parameter-overrides AlertEmail=singlebit.team@gmail.com AmiId=$AMI \
      --tags Project=orbit
    aws cloudformation describe-stacks --stack-name orbit-prod \
      --query 'Stacks[0].Outputs' --output table

    The first command looks up Canonical's newest Ubuntu 24.04 ARM image, once. The stack keeps that id from then on, so later updates (a bigger server, C3) never rebuild the server. About 5 minutes. It ends with a table: write down PublicIp and InstanceId.

  4. An email arrives: AWS Notification - Subscription Confirmation → Confirm subscription. That's where "the server is unhealthy" alerts go.

What the stack creates, all named orbit-prod…: its own network and firewall (ports 80 and 443 only), the server with a fixed address, a role that can only reach its own backup bucket, the backup bucket (kept 35 days), daily disk snapshots (7 kept), and two alarms that move the server to healthy hardware or reboot it if AWS's checks fail.

B3. Point the address at the server (domain admin, on Vercel)

NameTypeValueTTL
orbitAthe PublicIp60

singlebit.xyz has a wildcard that sends every unnamed address to Vercel; an explicit orbit record wins over it. If orbit.singlebit.xyz is attached to a Vercel project, remove it from that project first. Check from the laptop: dig +short orbit.singlebit.xyz prints the PublicIp.

B4. A terminal on the server

EC2 → Instances → orbit-prod → Connect → Session Manager → Connect. (If Connect is greyed out, wait 2–3 minutes after the stack finishes.) Type bash for a friendlier shell, then:

sudo tail -2 /var/log/orbit-bootstrap.log     # ends with: BOOTSTRAP OK
sudo cat /home/orbit/.ssh/id_ed25519.pub      # the deploy key, one line: ssh-ed25519 AAAA… orbit-prod

B5. The deploy key (GitHub)

github.com/CerealNotFound/orbit → Settings → Deploy keys → Add deploy key: title orbit-prod server, paste the line from B4, leave Allow write access unticked → Add key. The server can now read the repo and nothing else.

B6. Set the server up

sudo orbit-install

5–15 minutes. It installs Node 24, PostgreSQL 16, Caddy and Chromium's libraries, creates the database, writes /etc/orbit/orbit.env with fresh passwords and keys, and installs the services and the nightly backup. It stops with SETUP PAUSED, because there's no email key yet.

B7. The email key, and the first release

sudo orbit-env set RESEND_API_KEY      # paste the re_… key; nothing shows while you paste
sudo orbit-deploy                      # 3–10 minutes the first time (Chromium download)

orbit-deploy builds main, applies the database setup, starts Orbit and checks it's healthy. The last line says -> <commit>: OK.

B8. Check the site

Open https://orbit.singlebit.xyz. You should see the padlock and Orbit's sign-in page.

B9. The workspace, its companies and their admins

sudo nano /etc/orbit/bootstrap.json

Paste, with A1's real names and emails (Ctrl+O, Enter, Ctrl+X to save and close):

{
  "workspace": { "name": "SingleBit × Guiding Elephant", "slug": "singlebit-guiding-elephant" },
  "companies": [
    {
      "name": "SingleBit",
      "slug": "singlebit",
      "admin": { "name": "FULL NAME", "email": "EMAIL", "timezone": "Asia/Kolkata" },
      "workspaceAdmin": true
    },
    {
      "name": "Guiding Elephant",
      "slug": "guiding-elephant",
      "admin": { "name": "FULL NAME", "email": "EMAIL", "timezone": "Asia/Kolkata" }
    }
  ]
}

Then:

sudo chgrp orbit /etc/orbit/bootstrap.json && sudo chmod 640 /etc/orbit/bootstrap.json
sudo orbit-ops db:bootstrap --config /etc/orbit/bootstrap.json

Safe to run again; it only adds what's missing. Never run db:seed on the server: that's demo data.

B10. First sign-in

On https://orbit.singlebit.xyz, enter the SingleBit admin's email → open the link in the email → you're in. Settings → Members & roles → invite the team. The Guiding Elephant admin signs in the same way and invites their people.

B11. Save the secrets (do not skip)

sudo cat /etc/orbit/orbit.env

Copy the whole output into Orbit production settings in the password manager. It holds the database passwords, the sign-in secret, the Resend key and ORBIT_KMS_KEY, the credentials vault's key. Without it, saved credentials can't be decrypted, not even from a backup. It's also what moving to another host needs.

B12. First backup

sudo orbit-backup first                # prints the file, and "copied to s3://orbit-prod-backups-…"
sudo orbit-ops status                  # every service "active", health {"ok":true}

C1. The nightly backup (next morning)

sudo orbit-ops status → last backup shows a …-nightly.dump from about 02:30 IST. In CloudShell:

aws s3 ls s3://orbit-prod-backups-$(aws sts get-caller-identity --query Account --output text)/db/

C2. Restore drill (10 minutes, touches nothing live)

Restore last night's S3 copy into a scratch database on the server, count what's in it, and delete it:

f=$(sudo bash -c 'set -a; source /etc/orbit/orbit.env; aws s3 ls "$BACKUP_S3_URI" | tail -1 | awk "{print \$4}"')
sudo bash -c "set -a; source /etc/orbit/orbit.env; aws s3 cp \"\${BACKUP_S3_URI}$f\" /tmp/drill.dump"
sudo chown postgres /tmp/drill.dump
sudo -u postgres createdb orbit_drill
sudo -u postgres pg_restore --no-owner --dbname=orbit_drill /tmp/drill.dump
sudo -u postgres psql -d orbit_drill -Atc "select count(*) || ' users, ' || (select count(*) from tasks) || ' tasks' from users"
sudo -u postgres dropdb orbit_drill && sudo rm /tmp/drill.dump

The counts should match what you see in Orbit.

C3. Is t4g.micro enough?

sudo orbit-ops status shows memory and swap. Some swap is normal. If it stays above ~500 MB, or pages feel slow during the day, switch in CloudShell (the server reboots; about 2 minutes of downtime):

aws cloudformation deploy --stack-name orbit-prod --template-file orbit.yml \
  --capabilities CAPABILITY_NAMED_IAM --parameter-overrides InstanceType=t4g.small

Every other setting, including the image id, keeps its value. Take a backup first (sudo orbit-backup resize); the disk and the address stay the same.


4. If something goes wrong

SymptomWhat to do
The stack fails with VpcLimitExceeded or an Elastic IP limitThe account is at AWS's default limit (5 VPCs or 5 fixed IPs per region). Service Quotas → request +1, or ask Claude to switch the template to an existing network
orbit-install: "Couldn't clone"The deploy key isn't on GitHub yet (B5), or it was added to another repo
orbit-deploy: "Email isn't configured"B7's first line was skipped
orbit-deploy: BUILD FAILED or NEVER BECAME HEALTHYThe previous version keeps running. journalctl -u orbit-api -n 50 shows why; send it to Claude
Sign-in emails don't arriveResend → Logs shows each send and why it failed. Usually the domain isn't verified yet (A4)
The site is downsudo orbit-ops status; sudo systemctl restart orbit-api orbit-worker; logs as above

5. Undoing it all

On Vercel, delete the orbit record. In CloudShell, run aws cloudformation delete-stack --stack-name orbit-prod. Empty and delete the orbit-prod-backups-… bucket once you no longer need the backups (the stack keeps it on purpose). Remove the deploy key on GitHub and the domain in Resend. Nothing else in the account was touched.

6. What it costs (US East, always on)

ItemPer month
Server, t4g.micro$6.13
Fixed public address (shows as "Public IPv4 address" under VPC)$3.65
Disk, 20 GB gp3~$1.60
Disk snapshots (7, incremental) and S3 backupsunder $1
Alarms and the alert topic~$0.20
Totalabout $12 (t4g.small: about $18)
Resendfree up to 100 emails/day; $20 for 50,000/month

7. AWS words used here

WordWhat it means
Stack (CloudFormation)Everything Orbit uses on AWS, created and deleted together from one file, deploy/aws/orbit.yml
CloudShellA terminal inside the AWS console, already signed in as you
Session ManagerA terminal on the server through the console. No SSH keys and no open port 22
EC2 / t4g.microThe rented server: 2 ARM processors, 1 GB memory. Orbit adds 1 GB of swap (disk used as spare memory)
Elastic IPThe server's fixed public address, so DNS never has to change
Security groupThe server's firewall: only 80 (HTTP, for the certificate) and 443 (HTTPS) are open
IAM roleThe server's permissions, with no password to leak: Session Manager, and its own backup bucket
S3 bucketFile storage; here, the nightly database backups
EBS snapshotA copy of the whole disk, taken daily by AWS (Data Lifecycle Manager)
SNS topicThe email list the alarms write to

●  Have your own?

// Publish your own markdown.

Write your own