#Platform Plugin
Deploy your full-stack app to the cloud in one command. The platform plugin provisions databases, storage buckets, queues, pushes secrets, runs migrations, and deploys your code.
#Quick Start
Register the plugin in alepha.config.ts with the platform() helper:
1import { defineConfig } from "alepha/cli/config"; 2import { cloudflare, platform } from "alepha/cli/platform"; 3 4export default defineConfig({ 5 plugins: [ 6 platform({ 7 environments: { 8 production: cloudflare({ domain: "myapp.com" }), 9 },10 }),11 ],12});
alepha p up
Your app is live. Database created, secrets pushed, worker deployed.
#What It Does
Alepha introspects your application at build time. It scans for primitives - $entity, $storage, $cache - plus $job dispatch and registered cron jobs, and maps them to cloud resources on the target platform.
The deployment lifecycle runs in a fixed order:
authenticate → provision → build → migrate → deploy → secrets
Each step is handled by an adapter, and an environment names its adapter by calling the adapter's factory: cloudflare() (Workers, recommended) and bay() (self-hosted) ship with alepha/cli/platform, and lore() ships with @alepha/lore/cli (see the Lore adapter). There is no list of adapter names to extend: an adapter is an import, so writing your own needs nothing from the framework.
Alias: alepha p (or alepha platform).
#Options
Common flags accepted by most subcommands:
| Flag | Description |
|---|---|
--env, -e |
Target environment (default: "production") |
--verbose, -v |
Enable detailed output |
--json |
Machine-readable output |
#Configuration
platform() accepts the following options:
| Option | Type | Default | Description |
|---|---|---|---|
name |
string |
package.json name |
The app name: one workspace is one app. Used as the prefix of every resource name. |
default |
string |
"production" |
Default environment when --env is omitted. |
secrets |
object |
- | The secret key set override (keys), and an external store - see the secrets command. |
environments |
Record |
- | Named environments, each the result of an adapter factory: cloudflare(...), bay(...), lore(...). |
--env can only name a key of environments: anything else is refused before an adapter runs. Each environment's options are validated against its adapter's own schema when it is resolved, and a bad one is refused by environment name.
#cloudflare()
From alepha/cli/platform. Node only.
| Option | Type | Description |
|---|---|---|
domain |
string |
Custom domain, attached as a Cloudflare Custom Domain. A plain host: a wildcard is refused. Omit to use *.workers.dev. |
services |
Array<{ binding, service }> |
Worker-to-worker service bindings, exposed on the runtime env. |
jurisdiction |
"eu" | "fedramp" |
Cloudflare data jurisdiction for R2 buckets and D1 databases. |
accountId |
string |
Cloudflare account ID. Falls back to CLOUDFLARE_ACCOUNT_ID, then to the token's account when it is scoped to exactly one. |
A multi-tenant app on wildcard hosts (*.club.myapp.com) does not deploy through alepha platform: it deploys through Lore, one copy per tenant.
#bay()
From alepha/cli/platform. Node only.
| Option | Type | Description |
|---|---|---|
host |
string |
Required, here or through BAY_HOST. SSH destination of the Bay server (an ssh alias works). BAY_HOST overrides it. |
domain |
string |
Domain Bay registers for the app, which answers ACME for it. A plain host. |
socket |
string |
Absolute path of Bay's control socket - required on any host whose Bay root isn't $HOME/bay-data. See the Bay guide. |
#lore()
From @alepha/lore/cli. See the Lore adapter.
| Option | Type | Description |
|---|---|---|
project |
string |
The Lore project slug. LORE_PROJECT overrides, so lore() with no arguments is legal in CI. |
url |
string |
Origin of the Lore instance. Defaults to https://lore.alepha.dev; LORE_URL overrides. |
estate |
string |
The estate a copy created by the first up deploys to, by slug. Omitted, the one lent to the project first. |
1import { defineConfig } from "alepha/cli/config"; 2import { bay, cloudflare, platform } from "alepha/cli/platform"; 3 4export default defineConfig({ 5 plugins: [ 6 platform({ 7 name: "myapp", 8 environments: { 9 production: cloudflare({ domain: "myapp.com", jurisdiction: "eu" }),10 staging: cloudflare({ domain: "staging.myapp.com" }),11 edge: bay({ host: "deploy@bay.example.com" }),12 },13 }),14 ],15});
Settings shared by several environments repeat per environment; a plain const holding them is the way to share them.
#Secrets
Runtime secrets are pushed to the cloud provider's secret store during up. The key set to push is resolved by precedence:
platform.secrets.keys: explicit override inalepha.config.ts.- Otherwise, the union of every key your app declares via
$env(captured indist/manifest.jsonat build time) and any keys in.env.{env}.local.
Each key's value resolves from .env.{env} (then .env.{env}.local) first, then process.env - so CI can deliver secrets via the job environment with no .env file on the runner, while ambient runner variables (PATH, GITHUB_*, ...) can never leak.
STRIPE_SECRET_KEY=sk_live_...
SENDGRID_API_KEY=SG...
Variables handled by platform bindings or build config (DATABASE_URL, R2_BUCKET_NAME, HYPERDRIVE_ID, ...), framework infra knobs (LOG_LEVEL, SERVER_PORT, DEBUG, ...), and VITE_* variables are filtered out automatically. PUBLIC_URL is auto-derived from the configured domain unless you set it explicitly.
#Resource Naming
All cloud resources follow a deterministic naming convention:
<project>-<env>
For a project named acme deployed to production:
| Resource | Name |
|---|---|
| Worker | acme-production |
| D1 Database | acme-production |
| R2 Bucket | acme-production |
| KV Namespace | acme-production |
| Queue | acme-production |
Names are slugified - lowercase, alphanumeric and dashes, max 63 characters.
#Commands
#plan
Preview the deployment topology without touching anything. No authentication required.
alepha p plan
alepha p plan --env staging
alepha p plan --json
Shows: project name, environments, detected resources, resource names, and secret count.
#up
Full deployment pipeline. Runs all six lifecycle steps.
alepha p up
alepha p up --env staging
| Flag | Description |
|---|---|
--prebuilt |
Skip the Vite bundle steps; only regenerate the deploy config (wrangler.jsonc). Use when dist/ was already produced upstream. |
--tag no longer exists - it was removed with the artifact registry. A
programmatic caller of orchestrator.up({ ... }) still passing tag needs to
drop it.
#down
Tear down all resources for an environment. Requires --env.
alepha p down --env staging
Prompts for confirmation before deleting. Environments starting with tmp skip the confirmation, and --yes (-y) skips it for non-interactive callers (CI).
#status
Inspect what is currently deployed. Alias: alepha p s.
alepha p status
alepha p status --env staging
alepha p status --json
Shows: workers (deployed/not deployed, version, date), databases, buckets, KV namespaces, queues, and secrets (pushed/missing).
#auth
Manage the deploy credential explicitly:
alepha p auth login # opens the Wrangler OAuth flow; probes with a real API call
alepha p auth logout
login is rarely needed - the first alepha p up opens the same flow (see Prerequisites) - but it's the command to reach for when you want to switch accounts or verify credentials without deploying.
#build
Build only. No deployment.
alepha p build --env production
#deploy
Deploy only. Assumes already built.
alepha p deploy --env production
#db
Operations against the deployed database. They live under platform (not core alepha db) because they need the environment config, adapter, and resource naming.
# Run database migrations on the deployed database
alepha p db migrate --env production
# Pull the deployed database into a local snapshot (defaults to the dev DB path)
alepha p db export --env production
alepha p db export --output ./snapshot.db --keepSql
# Record the baseline migration as already applied on a deployed D1 database,
# without executing it (D1 only; --reset replaces an existing history)
alepha p db baseline mark --env production
#Placeholder blobs
An export copies rows, not objects. The file table arrives intact while the blobs it names stay in remote storage, so a local dev server would answer 404 for every image it is asked to serve - once per row.
db export therefore writes a stand-in blob for each file row, into the
directory LocalFileStorageProvider reads. Images become a grey
PLACEHOLDER square in their own format, so they render rather than breaking;
other types get a minimal valid file. Existing blobs are never overwritten, so
anything uploaded locally survives a re-export.
This never runs against production: the files are written by the CLI, to disk, at export time. Serving a stand-in when a blob is missing would need a development-only guard, and a guard that fails open would hide real data loss.
alepha p db export --env production --skipPlaceholders # leave the blobs missing
Placeholders are also skipped when --output points somewhere other than the
dev database, since the storage directory only serves the dev server.
#secrets
Sync secrets from .env.{env} to an external CI secret store - currently GitHub Actions environments via the gh CLI. This is separate from the runtime secrets pushed during up. Alias: alepha p sec.
alepha p secrets list # list remote secret names (--format=gha for a ready-to-paste env: block)
alepha p secrets diff # compare local .env.{env} keys against the remote store
alepha p secrets apply # push local secrets (upsert; never deletes) - --dry-run to preview
Configure the store in platform():
| Option | Type | Default | Description |
|---|---|---|---|
secrets.store |
"github" |
- | Secret store backend |
secrets.environmentPattern |
string |
"{project}-{env}" |
Pattern for resolving environment names in the store |
secrets.keys |
string[] |
auto | Override the worker secret-key allowlist used during up |
#Cloudflare Adapter
The Cloudflare adapter deploys your application as a Cloudflare Worker. It uses the Cloudflare REST API for resource provisioning and the Wrangler CLI for login, deployment, D1 migrations, and secret management.
#Prerequisites
- A Cloudflare account
wrangleris installed automatically if missing
On first run, alepha p up opens the Wrangler OAuth flow in your browser. The token is validated on every run (re-login is triggered automatically if it expired); account resolution is cached for 4 hours. In CI, set CLOUDFLARE_API_TOKEN instead.
#Resource Mapping
Alepha detects primitives in your code and maps them to Cloudflare resources:
| Primitive | Cloudflare Resource | Condition |
|---|---|---|
$entity / $repository |
D1 (SQLite) | DATABASE_URL is absent or not Postgres |
$entity / $repository |
Hyperdrive | DATABASE_URL starts with postgres: |
$storage |
R2 | Any $storage primitive detected |
$cache |
KV | Any $cache without an explicit provider (an explicit choice opts out of the platform default) |
$job |
Queue | JobQueueProvider registered (via AlephaApiJobsQueue) - i.e. $job dispatch routed through a broker. There is no $queue primitive; alepha/queue is the transport $job sits on, never called directly |
$websocket / $room |
Durable Objects | Either primitive detected - the ALEPHA_WEBSOCKET binding and its migration are written into wrangler.jsonc at build time |
$analytics |
Analytics Engine | Any $analytics primitive detected - the dataset binding (ANALYTICS) is named <project>-<env> unless CLOUDFLARE_ANALYTICS_DATASET is set in .env.{env} |
| Cron jobs | Cron Triggers | Any cron expression registered (configured at build time, not provisioned) |
D1, Hyperdrive, R2, KV, and Queue are provisioned via the Cloudflare REST API during the provision step. Cron triggers are written into wrangler.jsonc during the build step.
All provisioning is idempotent. If a resource already exists with the expected name, it is reused.
#Database: D1 vs Hyperdrive
The adapter chooses the database strategy based on DATABASE_URL in .env.{env}:
D1 (default) - If no DATABASE_URL is set, or it does not start with postgres:, the adapter provisions a Cloudflare D1 database (SQLite at the edge). Migrations are applied file by file with wrangler d1 execute --file, never wrangler d1 migrations apply, whose transaction wrapper cascade-deletes child rows on a table rebuild (see the Migrations guide).
Hyperdrive - If DATABASE_URL points to an external PostgreSQL database (postgres://...), the adapter provisions a Hyperdrive config instead. Hyperdrive accelerates connections from Workers to your Postgres database through connection pooling and caching. Migrations run via alepha db migrations apply directly against the database.
# .env.production: D1 (no DATABASE_URL, or d1:// protocol)
# Nothing to set. D1 is created and wired automatically.
# .env.production: Hyperdrive (external Postgres)
DATABASE_URL=postgres://user:pass@db.neon.tech:5432/mydb
#Build
The adapter runs alepha build -t cloudflare with environment variables injected from provisioned resources:
| Variable | Set When |
|---|---|
DATABASE_URL |
D1 provisioned (format: d1://name:id) |
HYPERDRIVE_ID |
Hyperdrive provisioned |
POSTGRES_SCHEMA |
Hyperdrive, when set in .env.{env} |
R2_BUCKET_NAME |
R2 provisioned |
CLOUDFLARE_KV_NAME |
KV provisioned |
CLOUDFLARE_KV_ID |
KV provisioned |
CLOUDFLARE_QUEUE_NAME |
Queue provisioned |
CLOUDFLARE_ANALYTICS_DATASET |
$analytics detected (derived, see below) |
CLOUDFLARE_DOMAIN |
Domain configured |
You do not set these manually. CLOUDFLARE_ANALYTICS_DATASET is the one the
adapter derives rather than provisions: whenever a $analytics primitive is
declared it emits an analytics_engine_datasets binding (bound as
ANALYTICS) named <project>-<env>, because Cloudflare creates the dataset
on the first data point and there is no id to pair with the name the way KV and
D1 need one. An explicit value in .env.{env} overrides the name, and is the
only way to get the binding for an app that writes to Workers Analytics Engine
without declaring $analytics.
That binding is write-only - env.ANALYTICS.writeDataPoint({...}), which
returns nothing and is not awaited. Reading the data back is a different
mechanism entirely: POST /accounts/{account_id}/analytics_engine/sql over
plain HTTP with a bearer token scoped Account · Account Analytics · Read. Note
that permission is account-wide - Cloudflare offers no per-dataset analytics
read scope - so think about where that token lives before putting it in a Worker
that also serves unauthenticated routes.
#Deploy
Deploys via wrangler deploy using the generated dist/wrangler.jsonc. Returns the live Worker URL.
#Teardown
alepha p down deletes resources in dependency order:
- Queue consumers (unbind from worker)
- Workers
- Queues
- KV namespaces
- R2 buckets (non-empty buckets are wiped via S3 credentials first when available)
- D1 databases / Hyperdrive configs
#Full Example
1import { defineConfig } from "alepha/cli/config"; 2import { cloudflare, platform } from "alepha/cli/platform"; 3 4export default defineConfig({ 5 plugins: [ 6 platform({ 7 environments: { 8 production: cloudflare({ 9 domain: "myapp.com",10 }),11 },12 }),13 ],14});
STRIPE_SECRET_KEY=sk_live_...
alepha p up
This authenticates with Cloudflare, provisions D1 + R2 + KV + Queue (based on your code), builds for Cloudflare Workers, runs D1 migrations, deploys the worker, and pushes STRIPE_SECRET_KEY as a secret.
#Temporary Environments
Prefix an environment name with tmp to create a throwaway deployment. Teardown skips the confirmation prompt.
1environments: {2 production: cloudflare({ domain: "myapp.com" }),3 staging: cloudflare({ domain: "staging.myapp.com" }),4 "tmp-pr-42": cloudflare(),5}
alepha p up --env tmp-pr-42
# ... test ...
alepha p down --env tmp-pr-42 # no confirmation
#The Lore adapter
lore() from @alepha/lore/cli makes alepha platform up deploy through Lore: the same command, whatever the destination.
1import { platform } from "alepha/cli/platform";2import { lore } from "@alepha/lore/cli";3 4platform({5 name: "docs",6 environments: {7 production: lore({ project: "alepha" }),8 },9});
The copy is platform().name and the environment's key. What differs from the other adapters:
- It builds and pushes, and Lore deploys.
upbuilds the runtime the copy's estate accepts (alepha build --runtime <that runtime>, run by the binary you invoked), pushes it aslatest, then starts the run in Lore and follows it to the end. Lore migrates server-side, so there is no local migration step. - The secrets are sealed by Lore. The same key set as every adapter is pushed into the copy's sealed set before the run starts. The push is additive: a key Lore holds that your
.envlacks is left alone,SIGIL_KEYis never written, and the names Lore reserves are skipped. - A first
upcreates the copy on the estate lent to the project first (orlore({ estate })). It is safe here because--envcan only name a key of your committed config. downkeeps the database and the bucket, removing the Worker, queue and cache, which the nextuprebuilds. Unless the copy is ephemeral: then it loses its data too, andalepha platform downrefuses it, even with--yes, naming thelore apps destroy --confirm <app>/<env>command to run instead.- The estate owns the host, so the address
upprints is the one Lore reports for the run.
alepha platform up or lore deploy? up always builds what is in the working tree and places it. lore deploy --tag 1.2.3 deploys a build Lore already stores, without building: promotion. Use up for the inner loop and the committed environments, lore deploy --tag to promote a tested artifact.
#Writing an adapter
An adapter is a class extending PlatformAdapter<TOptions>, with two statics: id, its display name in plan and status, and options, the schema its environment's options are validated against. It reads them from ctx.options.
1import { $module, z } from "alepha"; 2import { 3 type EnvironmentDescriptor, 4 PlatformAdapter, 5 type PlatformContext, 6 type PlatformState, 7} from "alepha/cli/platform-lib"; 8 9export interface ExampleOptions {10 region: string;11}12 13export class ExampleAdapter extends PlatformAdapter<ExampleOptions> {14 static readonly id = "example";15 static readonly options = z.object({ region: z.text() });16 17 async authenticate(ctx: PlatformContext<ExampleOptions>): Promise<void> {18 // Never prompt: `up` runs in CI.19 }20 21 async build(ctx: PlatformContext<ExampleOptions>): Promise<void> {}22 23 async deploy(24 ctx: PlatformContext<ExampleOptions>,25 ): Promise<string | undefined> {26 return `https://${ctx.project}.${ctx.options.region}.example.com`;27 }28 29 async inspect(): Promise<PlatformState> {30 return {31 workers: [],32 databases: [],33 buckets: [],34 kvNamespaces: [],35 queues: [],36 secrets: [],37 };38 }39 40 async teardown(): Promise<void> {}41}42 43export const AlephaExampleAdapter = $module({44 name: "example.platform",45 services: [ExampleAdapter],46});47 48export const example = (49 options: ExampleOptions,50): EnvironmentDescriptor<ExampleOptions> => ({51 adapter: ExampleAdapter,52 options,53});
Three rules keep it a good citizen:
- Its own
$module, with no$command.platform()registers each environment's adapter class when the config loads, which registers the module that declares it. A command declared in that module would appear inalepha --help. - The factory's declared return type is the generic
EnvironmentDescriptor, so a published.d.tsnames neither the adapter class nor anything it injects. - Secrets ride the deploy. The pipeline runs
deploythensecrets, so an adapter pushes them insidedeploy()and leavessecrets()empty, or the new build boots once without them.resolveSecretKeySetandselectSecretsfromalepha/cli/platform-libresolve the same key set every adapter uses.
Override provision, migrate, login, logout or exportDb when the target has one, and set controlsDomain = false when the adapter does not put the environment's domain into effect itself.
#Tips
Start with plan. Run alepha p plan before your first deploy. It shows what will be created without touching anything.
Use temporary environments for PRs. Name them tmp-pr-<number> and they tear down without confirmation. Great for preview deployments.
Keep secrets in .env.production. The platform plugin reads them automatically. Don't commit this file.
Check status after deploy. Run alepha p status to verify everything is live and secrets are pushed.