openship.json
The declarative deploy config for Openship — like vercel.json or railway.toml. Declare framework, build, runtime, env, domains, routes, resources, services and monorepo layout, and Openship deploys the same way every time.
openship.json is a repo-root file that tells Openship how to build, run, route, and scale a
project. It mirrors what you'd otherwise set in the deploy wizard,
so a repo becomes "ready to ship on Openship" and auto-deploy on push
and openship deploy become deterministic — no wizard needed.
Authoritative overlay
Openship auto-detects your stack first, then applies every field present in openship.json
on top. Fields you omit keep their detected value. So a good openship.json is small —
declare only what you want to override or pin, not the whole detected config.
Add the $schema line for editor autocomplete and inline validation:
{
"$schema": "https://openship.io/openship.schema.json",
"framework": "nextjs",
"port": 3000
}Validate it any time with the CLI:
openship config init # scaffold a starter openship.json
openship config validate # check it against the schemaIt's JSON, not JSONC — no comments, no trailing commas.
Build
Prop
Type
Runtime
Prop
Type
Persistent storage
Everything a container writes is discarded when the next version replaces it. volumes
names the paths to keep.
A bare path is relative to the app; full compose syntax works too, so you can point a mount at a specific volume name or a host directory:
{
"volumes": ["storage", "uploads:/app/public/uploads", "/srv/data:/app/var"]
}Omit the field to inherit the framework's own defaults (a Laravel app keeps storage/
with no configuration). Declare [] to turn persistence off entirely — that is
different from omitting it.
Named volumes are scoped per project, so two projects that both declare storage never
share one. On a bare (non-container) deploy the same paths are kept outside the release
directory and symlinked in. Openship Cloud has no volume primitive: a declared mount is
reported and skipped there, so use object storage for uploads instead.
Compose services declare their own mounts under services[].volumes.
Environment variables
env is an object. A value is a plain string, or { "value", "secret" } to mark a secret
(encrypted at rest — never returned to the client afterward).
{
"env": {
"NEXT_PUBLIC_URL": "https://app.acme.com",
"DATABASE_URL": { "value": "postgres://…", "secret": true }
}
}Domains
domains is an array of hostnames or objects. A bare label ("myapp") is a free
*.opsh.io-style subdomain; a dotted hostname ("app.acme.com") is a custom domain.
{ "domains": ["app.acme.com", { "domain": "api.acme.com", "port": 8080, "type": "custom" }] }Prop
Type
Routes
routes reproduces vercel.json-style routing, compiled to the reverse proxy at deploy.
Prop
Type
Resources
CPU/memory limits for the project's containers — a named tier, or explicit values (which become
the custom tier). See Sleep mode & resources.
Self-hosted defaults to no limits
On a self-hosted instance the default is unlimited — the machine is the ceiling, because it's
your hardware. Set a tier or explicit values only when you deliberately want to cap a container.
A non-zero value is validated against the target machine's real capacity, so a 64 GB box can
hand a container 64 GB.
On Openship Cloud a workspace is metered and must be sized, so unlimited isn't available there and
an omitted value falls back to the low tier.
Prop
Type
{
"resources": { "cpuCores": 4, "memoryMb": 8192 }
}Services (compose)
Declaring services makes the project a multi-service (Docker) project — see
Compose / multi-service. Each entry requires a name.
Already have a compose file? Don't re-declare it here — point composePath at it and leave
services out. That works for a compose file anywhere in the repo (deploy/docker-compose/),
under any filename (stack.yml), and keeps the file the single source of truth: its services are
re-read on every deploy, and build: contexts resolve relative to it, exactly as compose does.
{ "composePath": "deploy/docker-compose/docker-compose.yml" }{
"services": [
{ "name": "web", "build": ".", "ports": ["3000"], "exposed": true, "domain": "app.acme.com" },
{ "name": "db", "image": "postgres:17", "volumes": ["pgdata:/var/lib/postgresql/data"],
"env": { "POSTGRES_PASSWORD": { "value": "…", "secret": true } }, "restart": "unless-stopped" }
]
}Prop
Type
A compose file's own limits are honored too — Openship reads mem_limit / cpus and
deploy.resources.limits.{memory,cpus} off each service and applies them over the project-wide
setting.
Monorepo
monorepo overrides Openship's detected sub-apps. Entries in apps[] are matched to detected
sub-apps by rootDirectory and override their build settings — they don't declare apps from
scratch (the detector finds the apps).
{
"monorepo": {
"workspace": { "packageManager": "pnpm", "prepareCommand": "pnpm install && pnpm codegen" },
"apps": [
{ "name": "web", "rootDirectory": "apps/web", "framework": "nextjs", "port": 3000 },
{ "name": "api", "rootDirectory": "apps/api", "framework": "hono", "port": 8080 }
]
}
}Prop
Type
Not supported yet
sleepMode, monorepo sharedPaths, and per-app domain/env/exposed are accepted by the
validator but not applied — leave them out. Set sleep mode and per-app domains in the
dashboard for now.
How it maps
openship.json | Deploy wizard / API |
|---|---|
framework, packageManager, *Command, outputDirectory, buildImage, productionPaths, rootDirectory | Build settings |
port, productionMode, runtime | Runtime settings (hasServer, runtime isolation) |
volumes | Configuration → Persistent storage |
env | Environment variables (seeded as editable rows) |
domains | Public endpoints / custom domains |
routes | Reverse-proxy routing (routingConfig) |
resources | Cloud resource tier / custom sizing |
services | Compose services |
monorepo | Monorepo sub-apps + workspace |