Orbit: Deployment Plan
First production deploy of Orbit at
https://orbit.singlebit.xyz, written 28 October 2026 againstmain. Each step says who does it and exactly what to click or paste. The day-to-day runbook (releases, backups, restores, moving hosts) isdeploy/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
| # | Decision | Choice | Status |
|---|---|---|---|
| 1 | Where it runs | A new, separate stack orbit-prod in the AWS account Thnkrr uses. Nothing shared with Thnkrr | Decided |
| 2 | Region | US East (N. Virginia), us-east-1 | Decided |
| 3 | The server | EC2 t4g.micro (2 processors, 1 GB memory + 1 GB swap), Ubuntu 24.04 ARM, 20 GB disk | Decided |
| 4 | The database | PostgreSQL 16 on the same server, nightly backups to S3 and daily disk snapshots | Decided |
| 5 | Web app and HTTPS | Caddy on the server, one address for the app and its API, free certificate | Decided |
| 6 | Address | orbit.singlebit.xyz, DNS on Vercel | Decided |
| 7 | Resend, through an adapter: any SMTP provider is a settings change | Decided | |
| 8 | Lock-in | None by design: only the AWS template is AWS-specific; the rest runs on any Ubuntu server | Decided |
| 9 | Getting code on | A read-only GitHub deploy key; sudo orbit-deploy over Session Manager. No SSH, no port 22 | Decided |
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 code | Yes. Everything is on main; pnpm ci:local passes |
| The deploy kit | Yes. deploy/ (scripts and the AWS template), tested on a VM |
| Going live | Phases 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)
- A1. You: decide the first admins. One per company; they invite everyone else later.
- SingleBit: name, email, timezone. Also the Workspace Admin.
- Guiding Elephant: name, email, timezone.
- A2. You: a password-manager entry, Orbit production settings. Everything secret goes there.
- A3. You: Resend account, the domain
orbit.singlebit.xyzadded, an API key. (§3 A3) - A4. Domain admin: add Resend's 3 DNS records on Vercel. (§3 A4)
- A5. You, optional, ask the senior first: let the AWS bill show Orbit separately (activate
the
Projectcost tag). (§3 A5)
Phase B: AWS and the server (about 1 hour)
- B1. You: sign in to AWS with your Identity Center login; region N. Virginia.
- B2. You: create the
orbit-prodstack from CloudShell. Confirm the alert email. (§3 B2) - B3. Domain admin: point
orbit.singlebit.xyzat the stack's PublicIp. (§3 B3) - B4. You: open a terminal on the server (Session Manager); check the first boot. (§3 B4)
- B5. You: add the server's deploy key to the GitHub repo, read-only. (§3 B5)
- B6. You:
sudo orbit-install. Ends with SETUP PAUSED. (§3 B6) - B7. You: paste the Resend key, then
sudo orbit-deploy. Ends with OK. (§3 B7) - B8. You: open
https://orbit.singlebit.xyz: the padlock shows and the sign-in page loads. Resend shows the domain as Verified. (§3 B8) - B9. You: create the workspace, the two companies and their admins. (§3 B9)
- B10. You: sign in as the SingleBit admin with the emailed link. Invite the team from Settings → Members & roles. (§3 B10)
- B11. You: copy
/etc/orbit/orbit.envinto the password manager. Not optional. (§3 B11) - B12. You: first backup, and check it reached S3. (§3 B12)
Phase C: the first week
- C1. Next morning: the nightly backup exists, on the server and in S3. (§3 C1)
- C2. A restore drill into a scratch database: proves the backups work. (§3 C2)
- C3. After a few days of real use,
sudo orbit-ops status: if swap stays above ~500 MB or pages feel slow, move to t4g.small. (§3 C3) - C4. From now on, every release:
pnpm ci:localon the laptop → merge tomain→sudo orbit-deployon the server.
3. Every step, in detail
A3. Resend
- Sign up at resend.com with the team's address (
singlebit.team@gmail.com). - 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. - API Keys → Create API key: name
orbit-prod, permission Sending access, domainorbit.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.
| Name | Type | Value |
|---|---|---|
resend._domainkey.orbit | TXT | the long p=MIGf… value Resend shows (DKIM) |
send.orbit | MX | feedback-smtp.us-east-1.amazonses.com, priority 10 |
send.orbit | TXT | v=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)
-
AWS console, region US East (N. Virginia) (top right) → the CloudShell icon (top bar).
-
CloudShell → Actions → Upload file →
~/Documents/orbit/deploy/aws/orbit.yml. -
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 tableThe 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.
-
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)
| Name | Type | Value | TTL |
|---|---|---|---|
orbit | A | the PublicIp | 60 |
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.
- No padlock or "can't connect": DNS isn't pointing here yet (B3), or Caddy is still getting the
certificate. Check
dig +short orbit.singlebit.xyz, then on the serversudo systemctl reload caddyandjournalctl -u caddy -n 30. - In Resend → Domains,
orbit.singlebit.xyzshould say Verified (A4).
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
| Symptom | What to do |
|---|---|
The stack fails with VpcLimitExceeded or an Elastic IP limit | The 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 HEALTHY | The previous version keeps running. journalctl -u orbit-api -n 50 shows why; send it to Claude |
| Sign-in emails don't arrive | Resend → Logs shows each send and why it failed. Usually the domain isn't verified yet (A4) |
| The site is down | sudo 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)
| Item | Per 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 backups | under $1 |
| Alarms and the alert topic | ~$0.20 |
| Total | about $12 (t4g.small: about $18) |
| Resend | free up to 100 emails/day; $20 for 50,000/month |
7. AWS words used here
| Word | What it means |
|---|---|
| Stack (CloudFormation) | Everything Orbit uses on AWS, created and deleted together from one file, deploy/aws/orbit.yml |
| CloudShell | A terminal inside the AWS console, already signed in as you |
| Session Manager | A terminal on the server through the console. No SSH keys and no open port 22 |
| EC2 / t4g.micro | The rented server: 2 ARM processors, 1 GB memory. Orbit adds 1 GB of swap (disk used as spare memory) |
| Elastic IP | The server's fixed public address, so DNS never has to change |
| Security group | The server's firewall: only 80 (HTTP, for the certificate) and 443 (HTTPS) are open |
| IAM role | The server's permissions, with no password to leak: Session Manager, and its own backup bucket |
| S3 bucket | File storage; here, the nightly database backups |
| EBS snapshot | A copy of the whole disk, taken daily by AWS (Data Lifecycle Manager) |
| SNS topic | The email list the alarms write to |