The application is designed to boot with an empty environment. With no configuration at all it runs in demo mode - the landing page, dashboard and admin panel are all fully explorable against seeded data. Every section below is therefore optional to start and required to operate.
Quick start
git clone <your-repo> octic
cd octic
npm install
cp .env.example .env # every value may stay blank to start
npm run dev # http://localhost:3000With a blank .env, DEMO_MODE defaults to on and the app serves a frozen demo dataset. You will see:
Nothing is persisted in demo mode, and every simulated surface is labelled as such in the UI.
Going live, in order
- Provision Postgres and set
DATABASE_URL- see the database section. - Run
npm run db:deploy && npm run db:seed. - Create a Clerk application and set the two keys - see authentication.
- Create the IAM user and set the
AWS_*variables - see AWS IAM and EC2. - Set
DEMO_MODE="false". - Choose a DNS provider - see DNS automation.
- Deploy - see Vercel or AWS Amplify.
Database
Octic uses PostgreSQL. Any provider works; the schema uses enums and scalar arrays, so MySQL and SQLite are not drop-in substitutes.
Neon (recommended for serverless)
- Create a project at neon.tech.
- Open Connection Details and copy both strings: the pooled one into
DATABASE_URL, and the direct one intoDIRECT_URL.
DATABASE_URL="postgresql://user:pass@ep-xxx-pooler.us-east-2.aws.neon.tech/octic?sslmode=require"
DIRECT_URL="postgresql://user:pass@ep-xxx.us-east-2.aws.neon.tech/octic?sslmode=require"Supabase
Project Settings → Database → Connection string. Use the Connection pooling string for DATABASE_URL (port 6543) and the direct string for DIRECT_URL (port 5432). Append ?pgbouncer=true to the pooled URL.
Docker (local)
docker run -d --name octic-db \
-e POSTGRES_PASSWORD=octic \
-e POSTGRES_DB=octic \
-p 5432:5432 postgres:17-alpineDATABASE_URL="postgresql://postgres:octic@localhost:5432/octic"
DIRECT_URL="postgresql://postgres:octic@localhost:5432/octic"Migrations
npm run db:migrate # dev: create + apply a migration
npm run db:deploy # production: apply committed migrations
npm run db:seed # idempotent - safe to re-rundb:seed populates the games catalogue, plans, the 60-location list, cloud node rows, the domain pool and global settings. It is written with upsert throughout, so re-running it after a content change updates rows in place.
Authentication (Clerk)
- Create an application at dashboard.clerk.com.
- Enable the sign-in strategies you want - email plus Google and Discord are the usual picks for this audience.
- Under API Keys, copy both keys.
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY="pk_live_..."
CLERK_SECRET_KEY="sk_live_..."Add your production domain under Domains, and set the redirect URLs:
NEXT_PUBLIC_CLERK_SIGN_IN_URL="/sign-in"
NEXT_PUBLIC_CLERK_SIGN_UP_URL="/sign-up"
NEXT_PUBLIC_CLERK_SIGN_IN_FALLBACK_REDIRECT_URL="/dashboard"
NEXT_PUBLIC_CLERK_SIGN_UP_FALLBACK_REDIRECT_URL="/dashboard"Granting yourself admin
/admin is gated on a role, not on a boolean. Add your Clerk user id to the allowlist:
OWNER_CLERK_USER_IDS="user_2abc...,user_2def..."Find your id in the Clerk dashboard under Users, or sign in and read it from the debug panel at /dashboard. The allowlist takes precedence over the database role column, which means the first owner needs no database row to exist - important, because promoting yourself through the UI would require already being an admin. Comma-separate the value to list several owners.
How authorisation resolves
Clerk owns authentication; Octic owns authorisation. Users are mirrored into Postgres lazily on first request (lib/session.ts), so no webhook is required to get started. If you would rather sync eagerly - for example to send a welcome email - add a Clerk webhook pointing at /api/webhooks/clerk and set CLERK_WEBHOOK_SIGNING_SECRET.
AWS IAM and EC2 provisioning
Octic creates and manages EC2 instances on your behalf through @aws-sdk/client-ec2. This section sets up credentials that are powerful enough to run the platform and narrow enough that a bug cannot delete infrastructure Octic did not create.
1. Create the policy
IAM → Policies → Create policy → JSON, then paste:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ReadOnlyDiscovery",
"Effect": "Allow",
"Action": [
"ec2:DescribeInstances",
"ec2:DescribeInstanceStatus",
"ec2:DescribeInstanceTypes",
"ec2:DescribeImages",
"ec2:DescribeSecurityGroups",
"ec2:DescribeVpcs",
"ec2:DescribeSubnets",
"ec2:DescribeAvailabilityZones",
"ec2:DescribeRegions",
"ec2:DescribeTags"
],
"Resource": "*"
},
{
"Sid": "LaunchInstances",
"Effect": "Allow",
"Action": ["ec2:RunInstances", "ec2:CreateTags"],
"Resource": [
"arn:aws:ec2:*:*:instance/*",
"arn:aws:ec2:*:*:volume/*",
"arn:aws:ec2:*:*:network-interface/*",
"arn:aws:ec2:*:*:security-group/*",
"arn:aws:ec2:*:*:subnet/*",
"arn:aws:ec2:*:*:image/*",
"arn:aws:ec2:*:*:key-pair/*"
]
},
{
"Sid": "ManageOcticSecurityGroups",
"Effect": "Allow",
"Action": [
"ec2:CreateSecurityGroup",
"ec2:AuthorizeSecurityGroupIngress",
"ec2:RevokeSecurityGroupIngress",
"ec2:DeleteSecurityGroup"
],
"Resource": "*",
"Condition": {
"StringEquals": { "aws:RequestTag/ManagedBy": "octic" }
}
},
{
"Sid": "LifecycleOnlyForOcticInstances",
"Effect": "Allow",
"Action": [
"ec2:StartInstances",
"ec2:StopInstances",
"ec2:RebootInstances",
"ec2:TerminateInstances",
"ec2:ModifyInstanceAttribute"
],
"Resource": "arn:aws:ec2:*:*:instance/*",
"Condition": {
"StringEquals": { "ec2:ResourceTag/ManagedBy": "octic" }
}
}
]
}2. Create the user
- IAM → Users → Create user - name it
octic-platform. - Do not enable console access. This identity is for the API only.
- Attach the policy from step 1.
- Create access key → Application running outside AWS.
- Copy the key pair into your environment immediately - the secret is shown exactly once.
AWS_ACCESS_KEY_ID="AKIA..."
AWS_SECRET_ACCESS_KEY="..."
AWS_REGION="us-east-1"Prefer this over long-lived keys when you can:
- On EC2, ECS or Amplify SSR - attach an IAM role to the compute and omit the static keys entirely. The SDK resolves credentials from the instance metadata service automatically.
- Locally - use a named profile from
~/.aws/credentialsand setAWS_PROFILE=octic-platform.
3. Optional configuration
# SSH key pair, for break-glass access to a node. Optional but recommended -
# without it you cannot get a shell on a misbehaving instance.
AWS_EC2_KEY_PAIR_NAME="octic-nodes"
# Pin nodes to specific subnets (comma-separated; the first is used).
# Leave blank to let AWS pick the region's default VPC.
AWS_EC2_SUBNET_IDS="subnet-0abc...,subnet-0def..."
# Pin a specific AMI instead of resolving the newest Amazon Linux 2023.
AWS_EC2_AMI_ID="ami-0abc..."
# Validate every mutating call without changing anything. AWS returns
# DryRunOperation on success, which is a genuine permission smoke test.
AWS_EC2_DRY_RUN="true"4. What Octic creates per node
The provisioner (lib/aws.ts) resolves the newest Amazon Linux 2023 AMI for the region and CPU architecture (m7i-flex → x86_64, c7g → arm64), then:
- creates a security group named
octic-node-<name>, opening the game port on both TCP and UDP, plus19132/udpfor Bedrock cross-play and22/tcpfor SSH. It is idempotent - re-running authorises only the rules that are missing; - launches the instance with an encrypted gp3 root volume;
- enforces IMDSv2 (
HttpTokens: required). IMDSv1 is a well-known SSRF credential-theft vector and there is no reason for a new fleet to permit it; - tags the instance and volume
ManagedBy=octicandName=<node name>; - runs a cloud-init script that installs Amazon Corretto 21, creates an unprivileged
minecraftuser, registers a hardenedminecraft.servicesystemd unit, and applies network sysctls tuned for many concurrent players.
5. Cost guardrails
Set these up before you let anyone deploy:
- AWS Budgets - an alarm at 50, 80 and 100% of a monthly figure you are comfortable with. This is the single highest-value safety net.
- Service Quotas - cap Running On-Demand Standard instances to the number of nodes you actually intend to run. A runaway provisioner then hits a quota error rather than your credit card.
- Node.maxServers - the per-node application cap, enforced in the admin UI.
- AWS_EC2_DRY_RUN - leave it on until you have watched a full provision cycle succeed.
Instance pricing used by the admin cost monitor is defined in INSTANCE_TYPES in lib/aws.ts. These are approximate on-demand figures for us-east-1 and drift over time - reconcile against the AWS Pricing API or Cost Explorer before trusting the numbers in a report.
Free-plan accounts cap the plans you can sell
An AWS account still on the Free plan may only launch the instance types AWS marks free-tier eligible, whatever its credit balance - an upgrade, not credits, is what lifts the restriction. The largest eligible type has 8 GB, so every plan above that is unlaunchable on such an account.
Set AWS_EC2_FREE_TIER_ONLY="true" and Octic sizes deployments only from that eligible set (FREE_TIER_INSTANCE_TYPES in lib/aws.ts). A plan too large for the account is then refused at deploy time with plan_exceeds_account and a message naming the limit, rather than being placed on an undersized machine that the kernel would later kill under load. Set the flag back to "false" after upgrading.
DNS automation
Octic supports three modes, selected by DNS_PROVIDER:
manual is a complete product, not a degraded one - pooled subdomains still work, and external domains still verify by DNS polling. Automation only removes the copy-paste step.
The domain pool
Before any of this matters, add the base domains you own: Admin → Domain Pool → Add domain. Each entry has a hostname and a pattern describing how a customer’s chosen label becomes a hostname.
{sub} and {dash} are synonyms - both mean “the label the customer chose in the dashboard”. {server-id} uses the server’s cuid, which is useful for auto-generated hostnames that are never user-editable.
Adding a row here immediately populates the subdomain dropdown in every customer’s Custom Domains tab. There is no deploy step.
Labels are validated against RFC 1123 plus a reserved list (www, api, admin, play, mail, …) in lib/domains.ts. Add per-domain exclusions in the Domain Pool form for names that are only sensitive on one domain.
Route 53
- Register or transfer your domain, or add it as a hosted zone: Route 53 → Hosted zones → Create hosted zone.
- Point your registrar at the four
NSrecords Route 53 gives you. - Copy the hosted zone ID - it looks like
Z04987621ABCDEFGHIJKL. - Attach this policy to your IAM user:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ManageRecordsInOneZone",
"Effect": "Allow",
"Action": [
"route53:ChangeResourceRecordSets",
"route53:ListResourceRecordSets"
],
"Resource": "arn:aws:route53:::hostedzone/Z04987621ABCDEFGHIJKL"
},
{
"Sid": "PollChangeStatus",
"Effect": "Allow",
"Action": "route53:GetChange",
"Resource": "arn:aws:route53:::change/*"
}
]
}Scoping ChangeResourceRecordSets to a single zone ARN is what stops a bug from editing DNS for your entire account.
DNS_PROVIDER="route53"
AWS_ROUTE53_HOSTED_ZONE_ID="Z04987621ABCDEFGHIJKL"Cloudflare
- Add your domain to Cloudflare and move the nameservers at your registrar.
- My Profile → API Tokens → Create Token → Edit zone DNS template.
- Scope it to Specific zone → your domain. Do not use a Global API Key - it grants full account access and cannot be scoped or rotated per-service.
- Copy the token, then find the zone ID on the domain’s overview page.
DNS_PROVIDER="cloudflare"
CLOUDFLARE_API_TOKEN="..."
CLOUDFLARE_ZONE_ID="..."External domains (customer-owned)
When a customer connects play.mybrand.com, Octic stores the hostname with type EXTERNAL and a generated challenge token, then displays the two records to create:
It polls DNS for both, and flips the row to VERIFIED once they resolve. The full customer-facing walkthrough is in the DNS setup guide.
The TXT challenge is not redundant. A CNAME alone proves someone can edit DNS; it does not prove they own the domain. Requiring both prevents a third party pointing a hostname they do not control at your infrastructure.
Add-on providers
Modrinth
Works without configuration. Modrinth asks that you identify your client:
MODRINTH_USER_AGENT="octic.host/1.0 (contact@octic.host)"Responses are cached in-process for five minutes (lib/modrinth.ts) to stay well inside their rate limit.
CurseForge
The CurseForge third-party API requires an approved key. Apply at console.curseforge.com, create the key, and set:
CURSEFORGE_API_KEY="$2a$10$..."Without a key the CurseForge tab renders an explanatory empty state and the Modrinth tab continues to work - it does not break the add-on browser.
Deploying to Vercel
- Push the repository to GitHub.
- Vercel → Add New → Project → Import the repository.
- Framework preset: Next.js. Build and output settings can stay default.
- Add every variable from the environment reference under Settings → Environment Variables, for Production, Preview and Development.
- Confirm the
postinstallscript is present - Prisma’s client must be generated at build time.
{ "scripts": { "postinstall": "prisma generate" } }This is already in package.json. It matters because the generated client is not committed, and Vercel installs dependencies fresh on every build.
Deploy, then run migrations against the production database from your machine:
DATABASE_URL="<prod pooled>" DIRECT_URL="<prod direct>" npm run db:deployFinally, add your domain under Settings → Domains, and point the customer-facing base domains at the deployment.
Function configuration
Game-server actions (/api/aws/*) call the EC2 API and can take a few seconds. On the Hobby plan functions are capped at 10 seconds; provisioning usually completes well inside that, but if you see timeouts, move to Pro or set export const maxDuration = 30 in the relevant route handler.
Region
Choose a Vercel region close to your database, not to your users. The functions are thin - they issue a query and an AWS API call, both of which are far more sensitive to distance than the HTML response is.
Deploying to AWS Amplify
Amplify Hosting supports Next.js SSR, and pairs naturally with EC2 because it can use an IAM role instead of static keys.
1. Create a service role
- IAM → Roles → Create role → AWS service → Amplify.
- Name it
octic-amplify-ssr. - Attach the EC2 policy from the AWS section.
- Note the ARN.
2. Set up the app
Amplify → New app → Host web app, connect your repository. Amplify detects Next.js and generates amplify.yml - confirm it includes the Prisma generate step:
version: 1
frontend:
phases:
preBuild:
commands:
- npm ci
- npx prisma generate
build:
commands:
- npm run build
artifacts:
baseDirectory: .next
files:
- "**/*"
cache:
paths:
- node_modules/**/*
- .next/cache/**/*- App settings → Environment variables - add everything from the environment reference.
- App settings → IAM roles - set the service role to
octic-amplify-ssrand leaveAWS_ACCESS_KEY_IDandAWS_SECRET_ACCESS_KEYunset. The SDK picks up the role automatically, which means no long-lived secret exists anywhere in the deployment.
3. The Prisma engine
prisma/schema.prisma sets:
binaryTargets = ["native", "rhel-openssl-3.0.x"]Environment reference
Every variable the application reads. Anything not marked required may be left unset - the feature it configures either degrades to a documented fallback or is simply disabled.
Operations and security
Secrets
.envis git-ignored. Verify before your first push withgit check-ignore -v .env.AWS_SECRET_ACCESS_KEYandCLERK_SECRET_KEYare server-only. Never prefix them withNEXT_PUBLIC_- that inlines the value into the browser bundle.- Rotate the IAM key every 90 days: IAM → Users → Security credentials → Create access key, deploy the new key, then deactivate the old one and watch for errors before deleting it.
Least privilege
The lifecycle condition in the IAM policy (ec2:ResourceTag/ManagedBy: octic) is the most important control in this document. Do not remove it to “fix” a permission error - if Octic cannot terminate an instance, the instance is almost certainly not one it created, and that is the protection working.
Scaling notes
- One node hosts many servers.
Node.maxServersis an application-level cap; there is no bin-packing scheduler. Servers are placed on a node when provisioned and do not migrate. - Metrics are simulated unless you extend
getServerMetrics()inlib/servers.tsto query CloudWatch. The function is alreadyasync, so the swap needs no call-site changes. Real memory metrics require the CloudWatch agent, which the cloud-init script does not currently install. - Console output is simulated in demo and seeded modes. Streaming real output needs an agent process on the node - a websocket from the browser to the node’s log file, brokered through the control plane.
Backups
Set Ebs.VolumeSize and enable EBS snapshots through Amazon Data Lifecycle Manager. Snapshots are incremental and the only recovery path for a corrupted world; the application does not currently schedule them.
Troubleshooting
The failures below account for most of the support load. Each one has a specific cause.
Query engine binary for current platform not found
The Prisma binary target for the host is missing. Confirm binaryTargets = ["native", "rhel-openssl-3.0.x"] in prisma/schema.prisma, and that prisma generate runs in the build.
prisma migrate hangs or errors on a pooled connection
You are running migrations against DATABASE_URL instead of DIRECT_URL. PgBouncer cannot execute DDL. Set DIRECT_URL and re-run.
EC2 RunInstances failed (UnauthorizedOperation)
The IAM policy is missing a statement, or the aws:RequestTag/ManagedBy condition is failing because the tag is not being applied. Check that CreateTags is allowed for RunInstances - the LaunchInstances statement.
Sign-in redirects in a loop
NEXT_PUBLIC_CLERK_SIGN_IN_URL does not match the route. It must be /sign-in, and the catch-all route lives at app/(auth)/sign-in/[[...sign-in]]/page.tsx.
The dashboard shows demo data in production
DEMO_MODE is still "true". It defaults to on when Clerk keys are absent, so confirm both Clerk keys are set as well.
/admin redirects to the dashboard
Your Clerk user id is not in OWNER_CLERK_USER_IDS, or there is trailing whitespace in the list. The error query parameter on the redirect says which.
Arabic page renders left-to-right
components/DirectionSync.tsx sets dir on <html> after mount. If it is missing from the Arabic route, the wrapper dir="rtl" still applies to content but browser chrome stays LTR.
An external domain stays PENDING
DNS was edited but has not propagated, or the record is proxied. Verification uses resolveCname from node:dns/promises, which honours the resolver’s TTL - a recently changed record can take up to its TTL to appear. The dashboard shows the last check time; re-check rather than assuming failure. See the DNS setup guide for the customer-facing version of this list.