The self-hosted, push-to-deploy app platform — your own Vercel, on your own box.
Connect a GitHub repo, git push, and it clones → builds → containerizes → routes
with automatic HTTPS. Real containers, a real server, a flat cost you control. No
serverless metering, no per-request anxiety, no vendor lock-in.
Vercel is lovely until the bill is metered and your app lives on infrastructure you can't see. lla.ma Apps gives you the same push-to-deploy ergonomics on a box you own: one mental model — project → deployment → container — with auto-HTTPS in front.
- Push to deploy — a GitHub webhook (HMAC-verified) kicks off clone → Nixpacks
build →
docker run→ Caddy route, fully automated. - Automatic HTTPS — Caddy provisions and renews certificates per host.
- Live deploy state — a projects dashboard reads deployments straight from the orchestrator; build-log streaming is on the roadmap.
- Your box, your rules — flat cost, real containers you can
docker execinto, Postgres you can browse (with lla.ma base).
Two pieces. The control plane is the orchestrator — it holds the Docker socket, Caddy's admin API, and project secrets, so it runs private (binds loopback). The dashboard is a thin Next.js client of it.
┌────────┐ webhook (HMAC) ┌──────────────────────┐
│ GitHub │ ────────────────▶ │ Fastify control │ nixpacks build
└────────┘ │ plane (loopback) │ docker run
│ │ caddy admin API
┌───────────┐ API + SSE │ │
│ Dashboard │ ◀────────────▶ │ │ ◀──▶ Postgres (`deploy` schema)
│ (Next.js) │ └──────────┬───────────┘
└───────────┘ │ manages
▼
┌─────────────────────────────────────┐
internet ──▶ Caddy ─┤ container container container ... │
(TLS) └─────────────────────────────────────┘
The browser never talks to the control plane directly — Server Components and the
same-origin app/api/* route handlers proxy to it, keeping the orchestrator private.
The control plane builds and runs your apps as Docker containers — so Docker (and nixpacks) must be installed wherever the control plane runs. That one choice decides how much sits on your laptop and what your deploy URLs look like:
| Topology | Control plane + Docker on… | Your laptop runs | Deploy URLs |
|---|---|---|---|
| All-local | your laptop | everything | http://127.0.0.1:<port> |
| Your own box | a server you own | just the dashboard (CONTROL_PLANE_URL → the box) |
https://<app>.<your-domain> |
| Cloud (paid) | lla.ma's servers | just sign in | managed https://<app>.<domain> |
All-local is the zero-infrastructure way to try it — the trade is Docker on your
machine and localhost URLs. Point the dashboard at a box you own instead and your
laptop needs no Docker; add Caddy + a wildcard DNS record and you get real
https://<app>.<your-domain> deploys (Going public). The
cloud tier is the same dashboard with lla.ma running the box, Docker, TLS and DNS
for you. It's open-core: you can always self-host the whole thing for free.
Run the whole stack for FREE on the cloud — dashboard + control plane + Docker — with one command. Everything is hosted by lla.ma on the llama cloud:
Run the whole stack on your machine — dashboard + control plane + Docker — with one
command. Nothing is hosted by lla.ma: every server is yours, configured in .env.
Prerequisites
- Node 20+
- Docker running — builds run as real containers on this machine
- Nixpacks (
brew install nixpacks) — turns a repo into an image - A Postgres you control (local or remote) — the control plane stores state here
1. Point the control plane at your database. It's the only part that uses Postgres:
cd control-plane && npm install
cp .env.example .env # set PG_* (host / port / db / user / password)
cd ..The deploy schema (tables projects / deployments / domains / build_logs +
a seeded operator tenant) is created automatically on first run — no manual
psql step.
2. Point the dashboard at the control plane. The default is correct for local:
npm install
cp .env.example .env # CONTROL_PLANE_URL=http://127.0.0.1:8787 (default)Two
.envfiles, on purpose. The dashboard's.envonly holdsCONTROL_PLANE_URL. Your database goes incontrol-plane/.env(PG_*) — the dashboard never touches Postgres.
3. Run both together:
npm run dev # control plane (:8787) + dashboard (:3000), one processOpen http://localhost:3000. With no Caddy configured, deploys run as local
containers reachable at http://127.0.0.1:<port> — no public domain or TLS needed.
Run just one side with npm run dev:web (dashboard) or npm run dev inside
control-plane/.
Run it privately. This console can trigger deploys and reach secrets. It ships ungated — keep it on loopback, behind your own reverse proxy, or on a VPN. (The hosted edition adds an account gate; the OSS build deliberately has no phone-home.)
Local mode serves deploys at http://127.0.0.1:<port>. To serve real
https://<app>.<domain>, run Caddy and set
CADDY_ADMIN + DEPLOY_DOMAIN in control-plane/.env — the control plane then
registers each host and Caddy provisions TLS automatically.
To stand up a server end to end, infra/setup-app-box.sh
bootstraps a bare Ubuntu/Debian host — swap, Docker, Nixpacks, Caddy (loopback admin
- auto-HTTPS), a firewall, and a control-plane systemd unit:
sudo DEPLOY_DOMAIN=apps.example.com ADMIN_EMAIL=you@example.com bash infra/setup-app-box.shDrop in the control-plane code and start the service (the schema self-applies on
boot). Full walkthrough + the manual DNS/firewall steps: infra/README.md.
All config is environment variables. Dashboard:
| Variable | Default | Description |
|---|---|---|
CONTROL_PLANE_URL |
http://127.0.0.1:8787 |
Where the control plane is reachable |
Control plane — see control-plane/.env.example:
| Variable | Required | Description |
|---|---|---|
PG_* |
yes | Postgres connection (state lives in the deploy schema) |
CONTAINER_PORT |
yes | Port your apps listen on inside the container |
CADDY_ADMIN |
no | Caddy admin API. Unset = local mode (http://127.0.0.1:<port> URLs, no public TLS) |
DEPLOY_DOMAIN |
no | Apex for public hostnames; only used with CADDY_ADMIN |
GITHUB_WEBHOOK_SECRET / GITHUB_TOKEN |
no | Push-to-deploy webhooks and private repos |
BUILD_NODE_VERSION |
no | Node major for repos that don't pin one (default 22; .nvmrc/engines win) |
BUILD_PLATFORM |
no | Pin build/run arch, e.g. linux/amd64, for identical images on any host |
SHOT_HOOK_CMD |
no | Auto-refresh deployment thumbnails (see below) |
Each deploy turns your repo into a container two ways, in priority order:
- Your
Dockerfile— if the repo contains one, it's built directly (docker build). The escape hatch for anything the autobuilder can't handle (bleeding-edge toolchains, non-Node stacks, exact base images). The container must listen on$PORT(the platform sets it toCONTAINER_PORT). - Nixpacks autodetect — otherwise Nixpacks detects the framework and builds it. Repos that don't pin a Node version build with
BUILD_NODE_VERSION(default 22) instead of Nixpacks' EOL-18 fallback; a.nvmrcorengines.nodealways wins.
If an app needs a newer toolchain than Nixpacks ships, add a Dockerfile — that's the always-works path.
The projects grid shows a preview image per project (the "Vercel preview" look). These are captured by screenshotting each project's live deployment:
npm run shots # capture all running deployments
npm run shots -- --project proj_xxx # capture just oneIt reads the project → URL map from the control plane (CONTROL_PLANE_URL),
screenshots each running deployment over HTTPS, and writes
public/shots/<projectId>.jpg (gitignored — they're generated artifacts).
Projects whose latest deploy failed, or that have no public https:// URL, are
skipped. The dashboard falls back to a "no preview yet" placeholder until a shot
exists.
Auto-refresh on deploy: set SHOT_HOOK_CMD on the control plane to the capture
command, and it runs (detached, best-effort) whenever a deployment goes live, so the
thumbnail for that project refreshes automatically:
# in the control plane's environment
SHOT_HOOK_CMD=node /path/to/dashboard/scripts/capture-shots.mjsBoot-time refresh: SHOT_HOOK_CMD only fires on a new deploy — containers that
come back via Docker's --restart unless-stopped after a box reboot don't trigger it,
so their thumbnails can go stale or missing until each project's next deploy. If you
run the dashboard on the same box as the control plane, infra/setup-app-box.sh
templates a llama-shots.service systemd unit that re-shoots everything once at boot
(systemctl enable --now llama-shots after deploying the dashboard code — see
infra/README.md).
| Layer | Choice |
|---|---|
| Control plane | Fastify (TypeScript/ESM, Node) — the orchestrator |
| Dashboard | Next.js + React + Tailwind v4 — a thin client of the control plane |
| Build | Nixpacks (repo → OCI image) |
| Runtime | Docker |
| Routing / TLS | Caddy (admin API, auto-HTTPS) |
| State | Postgres (deploy schema) |
| Git | GitHub webhooks (HMAC-verified) |
The shared @lla-ma/ui design system
supplies the header/footer shell and theme tokens.
This repo is a small monorepo holding two separate applications plus the box provisioning kit:
llama-apps/
app/ lib/ scripts/ the DASHBOARD — Next.js, browser-facing (the only public surface)
control-plane/ the CONTROL PLANE — Fastify orchestrator, its own package + process
infra/ setup-app-box.sh + Caddy config — provisions a box to run both
The control plane is its own app, not a module of the dashboard. It has its own
control-plane/package.json, its own dependencies, its own tsc build (dist/),
its own .env, and it runs as its own process/service (the
llama-control-plane systemd unit) bound to loopback :8787. The dashboard's root
tsconfig.json even excludes it so each builds independently.
Why split them. The control plane holds the Docker socket, Caddy's admin API, and project secrets — root-equivalent power — so it must run private. The dashboard is the only piece meant to face a browser. They never share a process:
browser ──▶ dashboard (Next.js) ──HTTP──▶ control plane (Fastify, loopback :8787)
app/api/* proxies docker · nixpacks · caddy admin · Postgres
The dashboard (and its app/api/* route handlers) reach the control plane at
CONTROL_PLANE_URL; the browser never touches the control plane directly.
Deploying them (see infra/): both ship from this one repo, but land
as two things on the box — the control-plane folder built and run as the
llama-control-plane service (private), and the dashboard run/served separately as
the browser-facing surface. Same single repo, two independently-running apps.
They live together for convenience (one clone, one version), but nothing stops them being split into separate repos later — they only ever talk over HTTP.
- Webhook (HMAC) → clone → Nixpacks build → Docker run → Caddy route
- Projects dashboard + new project + deploy
- Live build-log streaming (SSE)
- GitHub webhook auto-registration (true zero-click push-to-deploy)
- Rollback (re-point Caddy to a prior container)
- Env-var management UI
- The agent layer — build-failure triage and conversational ops ("roll it back", "scale it") driving the same API. The real differentiator; later.
lla.ma Apps is free and self-hostable forever. A hosted edition (LLA.MA PRO) will add the things that need a service you don't run — managed builds, team access, and a single pane that fuses app hosting with the lla.ma base database console under one login. The platform you self-host stays complete on its own.
Apache-2.0 — see LICENSE.
