FrontierJS

Basecamp · alpha

Your servers,
one command center.

Basecamp is where you go when you stop building one app and start operating several. Ship a release, roll it back, see what is running where, and get your data back when something breaks — on boxes you own, with no one in the middle.

Deploy pipeline

Ship with one command. Take it back with one button.

A deploy becomes a release: built, loaded onto your machines, started, health-checked and written down. If the new container will not come up, the old one keeps serving.

  • Three ways to ship, one release. Run fli deploy, press Deploy on the App's page, or push to the branch it follows. Each is recorded with who shipped it, the commit and the image digest. From your laptop the image streams over ssh with docker save | docker load, so you need no registry.
  • Checked before anything is built. A required variable set nowhere, a database inside the container, a port the app does not listen on, or an env file baked into the image stops the release before the build. Whatever the App is missing is offered as one plan and applied when you say yes.
  • A bad release leaves the good one serving. Outpost sets the live container aside, starts the new image by digest, and checks its health ten times, three seconds apart. If it never answers, the old one goes back. An app with a data volume gets a copy of it before the swap.
  • Rollback ships the old release again. Roll back starts the image the previous release recorded, with the settings it shipped with, and rebuilds nothing. It takes an admin, and it does not rewind your data: restore the volume from the copy taken before the swap.
fli deploy preflight, then docker build on your machine→docker save | ssh … docker load onto every machine the App is placed on→POST /deployments one release, named by the image digest the machine reports→Validate · Back up volume the data volume is copied before the swap→Start container Outpost sets the live one aside and starts the digest→Health check healthy, the old container is removed; otherwise it is put back
A laptop release of a container App with a data volume, from packages/cli/commands/basecamp/deploy.md. The step names are the ones buildInitialSteps in deployments.service.ts writes, and the ones the release page lists. A Deploy press on a git App runs Build image on the machine instead, and an image App runs Pull image. The swap and put-back are packages/outpost/src/docker.js.

Read more: fli basecamp:deploy, releases and rollback, Outpost, the deploy hazards.

Outpost agent

Every box runs its own apps, certificates and replicas.

Outpost is the one process Basecamp installs on each server. It does what a signed command asks and reports back on a timer, so the box keeps serving while Basecamp is down.

  • It runs your containers. It pulls an image by digest or builds your repository on the box, swaps the new container in, and puts the old one back when the release does not answer.
  • Caddy serves HTTPS, and Outpost tells it what. Name a hostname and Outpost writes the app's route through Caddy's admin API, Caddy fetches the certificate, and the app's port moves to loopback.
  • Your SQLite data streams off the box. Outpost turns Basecamp's list into the machine's Litestream config, one bucket key per app, and every heartbeat carries Litestream's position so a stalled replica shows up as lag.
  • Small on purpose. It is plain JavaScript on Bun with no schema, no ORM and no migrations, because its job is running Docker and reporting on volumes and disk, and it refuses every command Basecamp did not sign.
Build on the box, or pull by digestRoute the hostnames in CaddyStop the live container, kept as _replacedStart the new oneHealth-check for up to 30 seconds
Healthy: answer the releaseNot: the old container comes back
What POST /deploy does on the box, in order: packages/outpost/src/server.js and the swap in src/docker.js. The old container is stopped first because a SQLite file takes one writer.

Read more: Outpost and its protocol · what bites on a box · backups and live replicas

Auth and trust

Built for the day a token leaks.

Who may read and change what is declared in the schema and enforced where the data is read, not left to each code path to remember. It rests on one value you keep yourself: lose ENCRYPTION_KEY and every encrypted column, in every backup taken under it, cannot be read back.

  • A workspace is a wall. One tenancy line scopes every workspace-owned row, and your role is read per request from your membership in the workspace you are addressing, so the same person can own one workspace and only view the next.
  • An API key is narrower than you. It belongs to one workspace, reaches only the services it was issued for (servers:read, projects:write), can never mint another key, and is kept only as an HMAC, so it is shown once and refused on its next request after you revoke it.
  • A machine proves it is that machine. It enrolls once with a single-use token that expires in 15 minutes and makes its own Ed25519 key pair, and from then on every report it sends and every command it accepts is signed and refused if stale or replayed, with no fleet-wide key to steal.
  • Secrets go in and do not come out. A secret's value is encrypted with AES-256-GCM and left out of every read, an admin's included, so only Basecamp decrypts it at the moment it is used, and the audit trail records that it changed, never what it holds.
// packages/basecamp/db/schema.lite, abridged
tenancy { strategy row  column workspaceId  claim workspaceId }

model Invitation {
  token        String        @unique(global) @secret @hashed
  @@gate(5)
}

model Secret {
  data        String     @default("{}") @encrypted
  @@gate(5)
  @@trail(audit)
}

model ApiKey {
  userId       String @immutable @default(auth().id)
  credentialId String?   @system
  scopes       String[]  @default([]) @immutable
  revokedAt    DateTime? @system
  @@gate(5)
  @@trail(audit)
}

model Server {
  enrollTokenHash  String?   @guarded
  enrollExpiresAt  DateTime?
  outpostPublicKey String?
  outpostCert      String?
  @@gate(2.4.4.5)
}
The lines that do it, from packages/basecamp/db/schema.lite. @@gate(5) means an admin or owner of the workspace; 2.4.4.5 is the role each of read, create, update and delete needs. @encrypted, @guarded and @hashed are enforced by Litestone at the data layer, and the trail logs them as [redacted].

Read more: the schema and its access ladder · enrollment and signed commands · how fields are encrypted · keeping the encryption key

Monitoring and alerting

Hear it from Basecamp, not from a customer.

Basecamp asks your URLs whether they are up, notices when a box stops checking in, and grades the numbers you set rules on. When something changes, the right person hears about it once, on the channel they chose.

  • Probes from outside, tolerant of a blip. A monitor is a URL, a method and the status codes that count (200-299,301 by default), checked every two minutes from the Basecamp process. It goes down only after its retries fail in a row, so one 502 behind a load balancer pages nobody, and a monitor can re-page every N checks while it stays down.
  • Alert rules fire on a breach that holds, and clear on a real reading. A rule names a metric, an operator, a threshold and how many minutes the breach must last. It is checked every minute against minute-level readings, and a metric that stops arriving never resolves an open alert or fires a new one. Snooze a rule for a maintenance window, up to a week, and it wakes by itself.
  • A quiet box and a failed job both get noticed. Outposts check in every 30 seconds by default. Past the grace you set (two minutes unless you change it) the server is marked unreachable and its workspace is told. A scheduled job keeps its cron schedule, records every run with output, retries, and tells your workspace when the last attempt fails.
  • Pagers and people are separate. A rule or monitor delivers to the channels you attach: Slack, PagerDuty, email, a webhook, or another FrontierJS app's chat. Each member also gets in-app notices, email where a mailer is configured, and a phone ping for the failures. Each person sets their own in-app and email preferences per kind of notice.
model Monitor {
  name                 String  @trim @length(1, 200)
  url                  String  @trim @url
  method               HttpMethod @default(GET)
  intervalSeconds      Int     @default(120)   @gte(60) @lte(86400)
  retries              Int     @default(2)     @gte(0) @lte(10)
  retryIntervalSeconds Int     @default(120)   @gte(60)
  timeoutMs            Int     @default(10000) @gte(1000) @lte(60000)
  acceptedStatus       String  @default("200-299") @trim @length(3, 200)
  isInverted           Boolean @default(false)
  resendEvery          Int     @default(0)     @gte(0)
  // …
}

model AlertRule {
  name         String   @trim @length(1, 200)
  severity     AlertSeverity @default(warning)
  metricName   String   @trim @length(1, 200)
  operator     ComparisonOp  @default(gt)
  threshold    Float
  forMinutes   Int      @default(0) @gte(0) @lte(1440)
  isActive     Boolean  @default(true)
  snoozedUntil DateTime? @system
  // …
}
Fields excerpted from packages/basecamp/db/schema.lite, models Monitor and AlertRule (comments and relations trimmed). Grading is domain/monitor/probe.ts and jobs/alert-evaluate.job.ts.

Read more: Basecamp layout · every job and its schedule · every notification and its transports · Outpost and its heartbeat

Backups and recovery

Lose the box. Get the data back.

Basecamp archives itself and every app it runs into a bucket you own, prunes the pile for you, and restores a hub from the setup page of a fresh install. You can run that restore on a scratch box today, before the day you need it.

  • Two kinds of archive, both off the machine. The hub's archive holds every database its schema declares, the audit trail and the job queue, each taken with VACUUM INTO so a write in flight cannot tear it. An app's archive is its data volume, tarred by the Outpost on its machine, on a cron per app and before any release of an app that has a volume.
  • A bucket you own, checked when you save it. Any S3-compatible bucket works; Backblaze B2 is the worked example and Cloudflare R2 the runner-up. Adding one writes and deletes a probe key at once, so a wrong key fails at the form, in words, not at 2 a.m. The hub keeps seven archives on disk and seven in the bucket by default, with an optional age limit, and never prunes the newest good one.
  • Restore is a page, not a runbook you write during the outage. On a new box with the old ENCRYPTION_KEY, /setup lists the archives in your bucket. Pick one and it is checked before a file is touched: a wrong key, a damaged file or a schema this version cannot carry on from is refused with its reason. Restart, and the old hub's owner signs in. The key is not in the archive, so Basecamp grades the hub red until you attest that you kept a copy off the box.
  • A drill that opens the copy, not just counts its bytes. An app with a live replica can be drilled on a schedule or by hand: the machine restores the replica into a scratch directory and runs the app's own release image over it, with no network, to verify it. The app's volume is never touched, and each answer is kept as a row.
Back up hub archive lands in the bucket→Lose it new box, same key→/setup List archives→Restore this checked, then staged→Restart the page notices, you sign in→Replicas found each app offers its restore
The path bun run verify:hub-restore drives in a browser: a hub backed up to a SeaweedFS bucket, restored at /setup across a restart, then its app and its own replicas found. From packages/basecamp/web/test/verify-hub-restore.mjs and docs/backups.md § 7. Proved against a local S3 stand-in; a live run on a real bucket is still owed.

Read more: the backups recipe, what a drill proves, the restore drive.

Start

One command buys the box.

Name a hostname. fli basecamp:create buys the server, installs Basecamp, points DNS at it and makes your first admin.

Basecamp is alpha and built in FrontierJS itself. It is a face over a documented path: everything it does is also available from fli and, beneath that, ssh.