Deploy to Orbita
A self-hosted PaaS that already knows what a Grit app is. It reads grit.json and derives the rest.
Grit apps specifically. It is the only target that reads grit.json, so a three-service deploy is about ten lines of orbita.yaml rather than a Compose file, a Blueprint or three TOMLs. Multi-tenant, so one box can host several clients in isolated organisations.
Non-Grit apps that need the fast path — those fall back to the Compose route, where only one service per app is routable. Also not for anyone who does not want to own an operating system.
Quick setup
- 1
Install it once on a fresh VPS
The installer brings Docker, Swarm, Postgres, Redis and Traefik with it, so there is nothing to set up beforehand. Give it 2 vCPU and 4 GB: Orbita idles around 50 MB, but building three services does not.
curl -sSL https://raw.githubusercontent.com/MUKE-coder/orbita/main/install.sh \| sudo ORBITA_DOMAIN=orbita.example.com ORBITA_ACME_EMAIL=you@example.com bash -s -- --yes - 2
Register first, immediately
The first account to register becomes super-admin, and public sign-up closes the moment it exists. Leaving that gap open on a public IP is the one genuinely dangerous minute in the install.
- 3
Write orbita.yaml
This is the whole deploy config. Addons replace the Postgres and Redis services you would declare in Compose, domains replace the Traefik labels, and migrate replaces the one-shot migrate container.
app: storefrontrepo: your-org/storefrontbranch: mainaddons: [postgres, redis]domains:web: example.comadmin: admin.example.comapi: api.example.commigrate: true - 4
Deploy, and look at the plan first
The dry run prints exactly what it will create before anything happens, which is the cheapest way to catch a wrong domain.
orbita deploy --plan --host prodorbita deploy --host prod
What catches people out
Back up /opt/orbita/.env. It holds ENCRYPTION_MASTER_KEY, from which every organisation key is derived — lose it and every stored secret is unrecoverable.
Migrations run before cutover under a Postgres advisory lock, and a non-zero exit aborts the deploy. The usual cause of a failure there is go.sum not being committed, so the one-off container cannot resolve modules.
On the Compose path only one service per app is routable, which is why a three-domain app wants the Grit fast path instead.
If your DNS is behind Cloudflare, set the records to DNS only for the first certificate — the proxy interferes with the ACME handshake.
Deploying Sentex on Orbita
The example application below is called sentex and its domains are on gritcms.com. Substitute your own project name and hostnames as you go — every other detail, including the service layout and the Compose translation, applies unchanged to any Grit project.
A self-contained guide for deploying Sentex to Orbita — the self-hosted, multi-tenant PaaS built by the same team as Grit. It targets the same monorepo layout (apps/api, apps/admin, apps/web, Postgres, Redis, migrate job) and the same three hosts used in the Railway, Dokploy, Coolify, Render and Fly.io guides.
Orbita is different from the other four platforms in one important way: because Sentex is a Grit app, Orbita recognises it and deploys the whole thing with almost no configuration. It reads grit.json, works out that this is a three-service app, builds the Dockerfiles Grit already ships, provisions Postgres and Redis for you, runs your migrations under a lock, and wires up all three domains — from a manifest that’s about ten lines long. You don’t translate your Compose file the way Render and Fly.io needed, and you don’t hand-edit it the way Coolify needed.
You install Orbita once (Part 1), then deploy Sentex to it. There are two ways to deploy, and they produce the same result:
Part 2 — the Grit fast path (recommended). Orbita derives everything from grit.json. This is the path that makes Orbita worth using for a Grit app.
Part 3 — the Docker Compose path (parity with Dokploy/Coolify). Keep docker-compose.prod.yml as your source of truth and let Orbita run it as a Swarm stack. Use this only if you specifically want the Compose file to stay authoritative.
Jump to the part you need. Part 1 is a prerequisite for both.
Part 1 — Stand up Orbita (once)
This installs Orbita on a VPS: it becomes your dashboard, your build server, and your reverse proxy. You do this once, then deploy as many apps (and as many tenants) as you like onto it. If you already have an Orbita server running, skip to Part 2.
The install brings Docker, Docker Swarm, PostgreSQL, Redis, and Traefik with it — there is nothing to set up beforehand.
1.1 Provision the VPS
- Spin up a VPS. 2 vCPU / 4 GB RAM is a sensible minimum — Orbita itself is tiny (~50 MB idle), but builds are hungry, and you’ll be building three Next.js/Go services plus running Postgres and Redis. 4 GB gives the builds room; go higher if you expect real traffic or multiple tenants.
- Use a fresh Ubuntu 22.04 or 24.04 box. Orbita’s Traefik needs to own ports 80 and 443, so don’t install it on a server that already runs nginx, Apache, or another panel — the installer will stop if it finds one.
- Note the server’s public IP and the root password (or the SSH key) the provider gave you. You’ll need them in 1.3.
Any of Contabo, Hetzner, DigitalOcean, or Vultr work well.
1.2 Point DNS at the server
Orbita’s Traefik terminates TLS directly on your server’s IP, so these are A records (not CNAMEs) — exactly like Dokploy in Part 1 of the alternatives guide. You need four hosts: one for the Orbita dashboard itself, and the three Sentex app hosts.
orbita.gritcms.com → <VPS public IP> # the Orbita dashboardsentex.gritcms.com → <VPS public IP> # webadmin.sentex.gritcms.com → <VPS public IP> # adminapi.sentex.gritcms.com → <VPS public IP> # api
If your DNS is behind Cloudflare, set each record to DNS only (grey cloud), not Proxied — Orbita fetches the Let’s Encrypt certificate itself, and the orange-cloud proxy gets in the way of that first handshake. You can turn it back on afterwards.
Confirm the dashboard record resolves before installing — certificates can’t be issued until it does:
dig orbita.gritcms.com +short # must print your VPS IP
1.3 Harden the server
Never skip this. A fresh VPS with root SSH open is a target within minutes. SSH in as root, then run the hardening script:
ssh root@<VPS public IP>curl -sSL https://raw.githubusercontent.com/MUKE-coder/vps-harden/main/vps-harden.sh -o vps-harden.shchmod +x vps-harden.shsudo ./vps-harden.sh --no-dokploy
It asks a few plain questions:
- A username for your everyday account — type
deploy. - An SSH port — press Enter to keep the default.
- Your SSH public key — paste your
~/.ssh/id_ed25519.pub, or leave it blank and the script generates a key and tells you where it saved it. - A password for the account — leave it blank and the script generates a strong one and prints it once at the end. Save that password.
When it finishes you have a deploy user with its own password and passwordless sudo, your key installed, root and password SSH logins disabled, a firewall (UFW + ufw-docker), Fail2ban, kernel hardening, and a 0–100 security score. --no-dokploy tells it not to install Dokploy, since Orbita is your platform here.
Before you disconnect: open a second terminal and confirm ssh deploy@<VPS public IP> works, so you don’t lock yourself out.
1.4 Install Orbita
From here you’re logged in as deploy (the deploy user reaches Docker through sudo — it’s deliberately not in the docker group, which is root-equivalent). Run the one-line installer, passing your dashboard domain and an email for Let’s Encrypt:
curl -sSL https://raw.githubusercontent.com/MUKE-coder/orbita/main/install.sh \| sudo ORBITA_DOMAIN=orbita.gritcms.com ORBITA_ACME_EMAIL=you@gritcms.com bash -s -- --yes
(If you skipped the domain in 1.2 and want to trial on the IP, drop the two env vars and Orbita comes up on http://<VPS public IP>:8080 with no TLS.)
In order, the installer installs Docker and starts Swarm, checks ports 80/443/8080, generates secrets into /opt/orbita/.env, pulls the Orbita image, starts all four services (orbita, orbita-postgres, orbita-redis, orbita-traefik), opens the firewall for the ports it needs, and waits for a healthy /health before printing your dashboard URL.
Verify:
curl -s http://localhost:8080/health # want {"status":"ok", ...}cd /opt/orbita && sudo docker compose ps # all four services "Up"
Back up /opt/orbita/.env. It holds ENCRYPTION_MASTER_KEY, from which every organisation’s encryption key is derived. Lose it and every stored secret (including the Sentex env you’re about to upload) is unrecoverable. Copy it somewhere safe before you put real data in.
1.5 Create your super-admin and organisation
Open the dashboard:
- With a domain:
https://orbita.gritcms.com - IP only:
http://<VPS public IP>:8080
Click Register and create your account immediately — the first person to register becomes the super-admin with full control of the box. Once that account exists, public sign-up closes automatically; nobody else can walk in and register. (Later teammates join by invitation, or through an account you create for them under Admin.)
Then create an organisation — your top-level workspace. Everything lives inside one, and each org is fully isolated: its own Docker network, its own encryption key, its own resource quota. Name it something like gritcms (this becomes the org slug, used to namespace networks and volumes).
If you’re running Sentex for a client and want to hand them the org, use Admin → Organisations → New tenant instead: it creates the org, sizes it (CPU/RAM/disk/app limits), and creates the client’s login in one step, showing you a generated password to hand over. They set their own password at first sign-in. For deploying your own app, a plain organisation is fine.
1.6 Connect GitHub
Sentex is a private repo, so Orbita needs a token to clone it and to register the auto-deploy webhook.
- In the dashboard, go to Settings → Git Connections.
- Add a GitHub connection with a Personal Access Token that has the
repoandadmin:repo_hookscopes.repolets Orbita clone the privatesentexrepository;admin:repo_hooklets it install the push-to-deploy webhook so future commits redeploy automatically.
That’s the whole platform set up. Everything below is per-app.
Part 2 — Deploy Sentex the Grit way (recommended)
This is the path that makes Orbita worth using for a Grit app. You write a short orbita.yaml, and Orbita derives the rest from grit.json.
2.1 What Orbita derives from grit.json (you write none of this)
A Grit app has a known shape, declared in grit.json at the repo root. Sentex has apps/api, apps/admin, and apps/web, which is Grit’s triple architecture. From that single fact, Orbita works out the entire deployment — this is the table you would otherwise have hand-written as a Compose file, render.yaml, or three fly.toml files in the other guides:
| From grit.json | Orbita derives for Sentex |
|---|---|
architecture: triple | Three containers: api, web, admin (plus docs if the repo has it) |
| The Dockerfiles Grit ships | Builds api from apps/api, and the Next.js apps from the repo root — the exact build.context values you set by hand in docker-compose.prod.yml |
| Ports | 8080 for the API, 3000 for the Next.js apps — no :port mapping to write |
NEXT_PUBLIC_API_URL | Baked into the admin and web bundles at build time from your api domain — the single most error-prone value in every other guide, derived here |
Orbita does not generate a Dockerfile and does not fall back to Nixpacks for a Grit app — it reuses the correct multi-stage Dockerfiles Grit already ships, the same ones the other platforms build.
2.2 Write orbita.yaml
Create orbita.yaml at the Sentex repo root. This is the whole deploy config — compare it to the ~90-line docker-compose.prod.yml, the render.yaml Blueprint, or the three fly.toml files:
app: sentexrepo: <your-org>/sentex # GitHub owner/name of the Sentex repobranch: mainaddons: # provisioned in this org's isolated network;- postgres # connection URLs injected into the app env- redisdomains:web: sentex.gritcms.comadmin: admin.sentex.gritcms.comapi: api.sentex.gritcms.commigrate: true # run the migrations under an advisory lock (default true)env:from: .env.production # local file; values are encrypted into Orbita, never committed
Notes on the choices, mapped to docker-compose.prod.yml:
addons: [postgres, redis]replaces thepostgresandredisservices you declared in Compose. Orbita provisions managed instances inside your org’s private Docker network and injects their connection URLs (e.g.DATABASE_URL,REDIS_URL) into the app — the values Grit’s code reads. You don’t setPOSTGRES_HOST/POSTGRES_PASSWORD/REDIS_URLyourself; that’s the point. (If Sentex uses Orbita’s object storage instead of Cloudflare R2, addminioto the list and Orbita injectsMINIO_*/STORAGE_DRIVERtoo. If you’re keeping R2, leaveminiooff and put theR2_*values in.env.production— see 2.3.)domainsreplaces the three TraefikHost()labels in your Compose file. Bare hostnames only — no scheme, port, or path. Orbita creates the routers and fetches Let’s Encrypt certs for all three.migrate: truereplaces the standalonemigrateservice and itsdepends_on: condition: service_completed_successfully. Orbita runscmd/migratein a one-off container before cutover, under a Postgres advisory lock. This is stronger than the Compose ordering guarantee: a non-zero exit aborts the deploy and the previous version keeps serving (see 2.8).
Two optional Grit toggles, both on by default, so you only add them to turn something off:
observability: true # Pulse — latency/SQL/error tracing on the APIsecurity: true # Sentinel — WAF, rate limiting, anomaly detection on the APIstudio: false # GORM Studio — off by default; it edits live data
2.3 Provide the environment values
Everything that isn’t derived comes from .env.production — the same file you’d paste into Dokploy or fill into Coolify, minus the Postgres/Redis values Orbita now supplies. That means the build-time and app-secret values Sentex needs:
THEME=atlasSOCIAL_AUTH_ENABLED=falseNEXT_PUBLIC_DEMO_LOGINS=false# Cloudflare R2 (only if you're keeping R2 instead of Orbita's minio addon)R2_ACCOUNT_ID=...R2_ACCESS_KEY_ID=...R2_SECRET_ACCESS_KEY=...R2_BUCKET=...R2_ENDPOINT=...# ...any other app secrets Sentex reads at runtime
You do not put NEXT_PUBLIC_API_URL, DATABASE_URL, REDIS_URL, POSTGRES_*, or the domain variables here — Orbita derives those from your domains and addons. When Orbita reads env.from, it encrypts every value into the org’s key at rest; the file is never committed and never leaves your machine in plaintext.
Now pick a route: 2.4 (dashboard) or 2.5 (CLI). They do the same thing.
2.4 Route A — deploy from the dashboard
No CLI needed; everything happens in the browser.
- In your organisation, create a Project (e.g. Sentex) and an Environment (e.g. production) inside it. Apps live under project → environment.
- Click Create App → Source: Git Repository. Pick the GitHub connection from 1.6, then the
sentexrepo and themainbranch. - Because the repo has a
grit.json, Orbita recognises it as a Grit app and uses the fast path — you don’t choose a builder or a Dockerfile. Confirm the three derived domains match your DNS from 1.2:sentex.gritcms.com,admin.sentex.gritcms.com,api.sentex.gritcms.com. - Open the app’s Environment tab and paste the contents of
.env.production(oneKEY=VALUEper line). Mark theR2_*keys and any secrets as secret so they’re encrypted at rest and never shown again. - Click Deploy.
Watch the Deployments tab. Orbita builds all three services, provisions Postgres and Redis, runs the migration, and cuts over only if it succeeds. Skip to 2.6 for what you’re watching.
2.5 Route B — deploy with the CLI
The orbita CLI is optional, but it’s the tidiest way to deploy from your machine and keep orbita.yaml as the source of truth. Today it’s built from source — there’s no curl | sh installer yet, and go install doesn’t work because of the module path — so build it once from a clone (requires Go 1.25+):
git clone https://github.com/MUKE-coder/orbita.gitcd orbitamake build-clisudo mv ./orbita /usr/local/bin/orbitaorbita --help
It won’t clash with Grit’s own grit binary — different repo, different name.
Then, from the Sentex project directory (the one with grit.json and the orbita.yaml you wrote in 2.2):
# Register your server with the CLI (once). Prompts for the admin email +# password you created in 1.5, mints a deploy token, saves the host as "prod".orbita login https://orbita.gritcms.com# Store a GitHub token (repo + admin:repo_hook) so Orbita can push/clone (once).orbita github-auth# Preview the plan without changing anything — highly recommended first run.orbita deploy --plan --host prod
The plan prints exactly what it will create, so you can confirm the mode and domains before anything happens:
▸ Plan (dry run — nothing will be changed)App: sentexMode: tripleMigrate: trueAddons: postgres, rediscreate sentex-api → api.sentex.gritcms.comcreate sentex-web → sentex.gritcms.comcreate sentex-admin → admin.sentex.gritcms.com
When it looks right, deploy for real:
orbita deploy --host prod
(If you don’t yet have an Orbita server at all, orbita init collapses all of Part 1 — harden, install, admin account, host registration — into one interactive command from your machine. Use it instead of Part 1, not as well.)
2.6 What happens, in order
Whichever route you used, a deploy runs these steps — this is the pipeline the other four platforms make you assemble by hand:
- Detect —
grit.jsonat the repo root marks it a Grit app;architecture: triplepicks the three-service strategy. - Ensure the repo — Orbita confirms it can reach the
sentexrepo with your token (and, over the CLI, pushes your current commit). - Reconcile — org, project, environment, the
postgres+redisaddons, your encrypted env, and the three domains. Idempotent — safe to re-run. - Build —
api,web, andadminfrom the Dockerfiles Grit ships, withNEXT_PUBLIC_API_URLbaked into the two Next.js bundles from your api domain. - Migrate —
cmd/migratein a one-off container, under a Postgres advisory lock so two concurrent deploys can’t race. - Cut over — only if the migration exited 0. The previous images are kept for instant rollback.
- Route — Traefik serves all three domains over HTTPS. Certs are issued on the first request to each host.
2.7 Verify
- Visit
https://api.sentex.gritcms.com/<health endpoint>and confirm it answers. - Visit
https://sentex.gritcms.comandhttps://admin.sentex.gritcms.comand confirm there are no CORS errors in the browser console — if there are, the API domain baked into the frontend bundle doesn’t matchapi’s real domain; recheckdomains.apiinorbita.yaml. - Log in with the seeded demo SACCO credentials to confirm the migration and seed actually ran.
From the CLI you can also stream logs and confirm the migration:
orbita logs -f --host prod # all servicesorbita logs --host prod --service migrate # just the migration job
2.8 Migrations gate the cutover (troubleshooting)
Orbita runs your migrations before it cuts over, under an advisory lock. A non-zero exit stops the deploy and leaves the previous version serving — you never end up on a schema-mismatched image. If a deploy fails at the migrate step, that’s why.
The most common cause with a Grit app is go.sum not being committed, so go run ./cmd/migrate can’t resolve modules inside the one-off container. Commit it — real Grit apps ship it — and redeploy. Check the migrate log:
orbita logs --host prod --service migrate
2.9 Batteries included (Pulse, Sentinel, Studio)
Because Sentex is a Grit app, Orbita mounts these on the API by default — no setup:
- Pulse — latency, SQL, and error tracing →
https://api.sentex.gritcms.com/pulse/ui - Sentinel — WAF, rate limiting, anomaly detection →
https://api.sentex.gritcms.com/sentinel/ui - GORM Studio — off by default because it edits live data. Turn it on with
studio: trueinorbita.yamlonly when you need it.
2.10 Ongoing deploys
Because you connected GitHub in 1.6, Orbita installed a push-to-deploy webhook when it created the app. Every push to main now:
- re-clones the repo and rebuilds the changed services,
- reruns
cmd/migrateunder the lock (idempotent, so this is safe every time), - cuts over only if the migration succeeds.
No manual redeploy step — the same GitHub-connected flow as Dokploy and Coolify. To revert a bad deploy, orbita rollback --host prod (or the Rollback button on a previous deployment in the dashboard) swaps back to the previous image instantly, since Orbita keeps it.
Part 3 — Alternative: run your Compose file on Orbita
Use this only if you specifically want docker-compose.prod.yml to stay the source of truth — for example, to keep one Compose file working identically across Dokploy, Coolify, and Orbita. For a Grit app, Part 2 is simpler and gives you migrations-under-a-lock, provisioned addons, and the observability mounts that this path does not. This path treats Sentex as a generic multi-service stack, not as a Grit app.
Orbita deploys a Compose file as a Docker Swarm stack. It runs the file essentially as-is — you do not strip networks or labels the way Coolify required, and you do not translate it to another format the way Render and Fly.io required.
3.1 Create the app from Docker Compose
- In your project/environment, click Create App → Source: Docker Compose.
- Point it at the compose file:
- From your Git repo (recommended, so pushes redeploy): pick the GitHub connection, the
sentexrepo and branch, and set the compose file path todocker-compose.prod.yml. - Or paste it inline — but note a pasted file can’t use
build:(there’s no source tree to build from), so it must reference prebuilt images. Sentex builds from source, so use the Git option.
- From your Git repo (recommended, so pushes redeploy): pick the GitHub connection, the
- Set the web service to the service that serves your primary domain —
webfor Sentex. This is the service Orbita routes your app domain to; the others stay private to the stack, reachable by their compose service name (api,postgres,redis) exactly as they are locally. - Set the port to the web service’s container port —
3000for the Sentexwebservice. Port is required for Compose apps, because that’s what the domain routes to.
3.2 Domains
Add your domains under the app’s Domains tab. Only the nominated web service is routable from a single Compose app, so:
- Add
sentex.gritcms.com→ routes to thewebservice you nominated. - To give
apiandadmintheir own domains, the clean approach on this path is to deploy each as its own app (three Compose apps, or better, use the Grit fast path in Part 2 which does all three at once). A single Compose app exposes one routable service.
This is the main reason Part 2 is preferable for a three-domain app like Sentex — the Grit fast path routes all three hosts from one deployment.
3.3 Environment
Open the app’s Environment tab and paste your .env.production values. Orbita injects them into every service in the stack (so a worker gets the same DATABASE_URL the web tier does), and they’re encrypted at rest. A service’s own environment: block in the compose file still wins if it sets the same key. ${VAR} references in the compose file are interpolated from these values too, matching env_file: [.env].
3.4 Deploy and verify
- Click Deploy. Orbita builds the services that declare
build:, then runsdocker stack deployfor the whole file. - Watch the deploy log. Because this is a real Swarm deploy of your Compose file, the
migrateservice’sdepends_on: service_completed_successfullyordering works unmodified — same as Dokploy/Coolify. - Visit your web domain and confirm it serves.
3.5 Limits worth knowing on the Compose path
- Only the web service is routable per Compose app (see 3.2).
- No rollback for Compose apps — a Compose deploy has no single image to revert to. Redeploy the previous commit instead. (The Grit path in Part 2 does support instant rollback.)
build:needs a Git repo — pasted YAML must use prebuilt images.- Stopping, starting, or deleting the app applies to every service in the stack.
Quick comparison
Why Orbita’s Grit path is the shortest of them all for Sentex: it’s the only one that already knows what a Grit app is. The others need you to describe a three-service app in their own dialect (Compose, Blueprint, or three TOMLs); Orbita reads the same grit.json your app already ships and derives the rest — addons, ports, build contexts, the API URL baked into the frontends, and migrations under a lock — from that.
Platform dashboards and CLI flags change faster than these docs. For anything that looks different from what is written here, Orbita's own documentation is the authority: github.com/MUKE-coder/orbita
