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.
When refreshing services for a project with a saved subfolder, Openship uses that subfolder's
openship.json when present. Otherwise, it uses the repository-root manifest, preserving existing
service definitions and environment defaults. The saved build directory still applies.
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 version selection
runtimeVersion is one setting shared by runtime policies; it is not a global PHP enum.
The selected or detected stack validates supported versions and chooses matching build/runtime images.
Explicit selection currently supports PHP 8.3 and 8.4 for generated Docker builds. Other runtimes
keep their existing repository detection and defaults; an explicit unsupported selection fails
before a project write or build instead of being ignored.
For the Laravel and Symfony stacks, select a version in the deploy wizard's
build settings or declare it in openship.json:
{
"framework": "laravel",
"runtimeVersion": "8.3",
"installCommand": "composer install --no-dev --optimize-autoloader"
}| Selection | Builder | Runtime |
|---|---|---|
| Not declared (unchanged default) | php:8.4-cli | dunglas/frankenphp:1-php8.4-bookworm |
8.3 | php:8.3-cli-bookworm | dunglas/frankenphp:1-php8.3-bookworm |
8.4 | php:8.4-cli-bookworm | dunglas/frankenphp:1-php8.4-bookworm |
A new selection keeps Composer installation enabled even when there is no JavaScript asset build. You can disable Build when your source already contains its prepared dependencies.
These are the currently tested selections. Other major/minor or patch selections are rejected.
Both selected stages verify their actual PHP major/minor version. The generated recipe checks
production Composer requirements in the builder and runtime, including PHP and extensions.
Composer's platform check uses the real
platform, even if config.platform or the install command's --ignore-platform-reqs pretends otherwise.
An incompatible build fails before activation, leaving the current deployment available.
A declared buildImage overrides the managed builder, while the selected FrankenPHP runtime stays
fixed. The custom builder must provide the selected PHP version and support the generated
Composer/extension setup; known incompatible official PHP tags are rejected immediately and
custom images are checked during the build. Changing the version replaces a previously managed
builder default, but does not overwrite a custom builder. When you select a different version with
a custom builder, update both settings together.
This project-level setting applies to generated Docker builds on self-hosted and managed Cloud
Docker destinations. It cannot configure a user-owned Dockerfile, Compose/monorepo service image,
bare runtime, static site, or prebuilt image. Explicit selection on these paths is refused; configure
PHP in their own image instead. Existing projects and user Dockerfiles without runtimeVersion are unchanged.
The API and SDK accept runtimeVersion in project creation, ensure/update and build options. Send
null in project options to restore the default PHP version; custom builder overrides are preserved. Folder scans, the SDK source deployment flow,
and openship deploy --folder preserve repository declarations. The CLI validates this field with
openship config validate. Version and resolved image references are frozen in each deployment
snapshot; rollback reuses that release's retained application image rather than the latest project
setting. Openship Edge, TLS, routing and resource monitoring continue through the existing deployment path.
Runtime
Prop
Type
Laravel cache warming
php artisan optimize warms Laravel's config, route, event, and view caches. A
warmed web role answers each request faster. The default is off, so an existing
project keeps its exact web command until it opts in.
Warming helps the web role only. It does not fix cold start, because cold start is dominated by PHP opcode compilation, not cache state. Workers and schedulers gain little, so leave them unwarmed.
Run optimize at startup, never at build
optimize runs config:cache. config:cache freezes whatever environment is
present when it runs. After that, every env() call outside config/*.php
returns null.
Warm the cache at startup, after Openship injects the runtime environment. Do
not warm it in buildCommand, which freezes build-time values. Do not warm it
in releaseCommands, which runs in a throwaway container whose
bootstrap/cache is discarded. Read configuration through config() and keep
env() inside config/*.php.
Opt-in flag
Set optimize: true to warm the web role. The flag prefixes the web command and
keeps the rest of the command unchanged.
{
"framework": "laravel",
"optimize": true
}Warming runs before the server through &&. A failing php artisan optimize
short-circuits the chain, so the web role never boots. Opt in only when the
command succeeds reliably in your runtime environment.
optimize: true always prepends php artisan optimize && . Do not also put
php artisan optimize in a custom startCommand, or it runs twice. The repeat
is harmless but wasteful.
Override the web command directly
You can warm the web role without the flag. Override startCommand and keep the
exec form. optimize runs first. exec then hands PID 1 to FrankenPHP, so
SIGTERM still drains in-flight requests.
{
"framework": "laravel",
"startCommand": "php artisan optimize && SERVER_NAME=\":$PORT\" exec frankenphp run --config /etc/frankenphp/Caddyfile --adapter caddyfile"
}Monorepo caveat
Neither the optimize prefix nor a manual startCommand override is applied to
a Laravel sub-app selected inside a JavaScript-workspace monorepo — a repo root
that declares a pnpm, npm, yarn, or Bun workspace. In that layout the
sub-app's web command is re-derived from the bare preset. This is pre-existing
behavior and not specific to optimize. Standalone Laravel repos, and Laravel
repos without a JavaScript workspace at the root, are unaffected.
Warm a worker or scheduler
Warming a worker gives a small, one-time benefit, so prefer warming the web role
only. To warm a worker anyway, declare it in roles, and keep any other roles you
declared there. Prefix its command with php artisan optimize && and keep the
exec form. The fields and rules are in
Runtime roles.
{
"roles": [
{ "name": "queue", "kind": "worker", "command": "php artisan optimize && exec php artisan queue:work", "stopGracePeriod": "90s" },
{ "name": "scheduler", "kind": "scheduler", "command": "php artisan optimize && exec php artisan schedule:work", "stopSignal": "SIGTERM", "stopGracePeriod": "10s", "singleton": true }
]
}These are example shutdown budgets. Longer scheduled runs may need a larger grace period on a Laravel version verified to drain.
Custom runtime images
Docker source builds offer a Runtime image field under Advanced build settings.
It is a production base image, separate from the builder and the final application image.
Omit it in openship.json, or send runtimeImage: null through project options, to restore the managed default.
{
"framework": "laravel",
"runtimeVersion": "8.3",
"runtimeImage": "registry.example.com/team/php-runtime:8.3"
}The stack selects its Docker recipe, even when a custom image has a different name.
A PHP runtime base must provide the selected PHP version, FrankenPHP, install-php-extensions,
and the www-data user. The recipe checks these capabilities and Composer's actual platform.
Known incompatible official PHP image/version combinations are rejected before settings are saved.
Other custom interpreter images compare the actual builder/runtime fingerprint during the build
(runtime ABI or version, architecture, and libc where applicable). Compiled-native applications
continue through deployment readiness checks. A failed build does not replace the live application.
Overrides must support the generated recipe's shell, directories and package setup. Use your own Dockerfile for a different server implementation or filesystem layout. Project-level overrides are refused for bare runtimes, prebuilt images, static extraction and service-owned Compose/monorepo builds. Those paths manage their images directly.
Omitted options preserve a saved override; null clears it. A version change preserves custom
image overrides and validates the new combination. Version, resolved images and the override
flag are frozen in each deployment snapshot, so retained-image rollback restores its original
application without changing the current project settings.
Release commands
releaseCommands runs once per deploy, after the build and before the new version goes
live: the place migrations belong. Each command runs in order, its output streams into
the deploy log, and a non-zero exit fails the deploy: nothing cuts over, and the
version that was already serving keeps serving.
{
"releaseCommands": ["php artisan migrate --force"]
}Nothing is injected for you. A framework's release set is a choice, so an app with no
releaseCommands runs no release phase at all. The equivalents elsewhere:
| Stack | Typical release commands |
|---|---|
| Laravel | php artisan migrate --force |
| Rails | bundle exec rails db:prepare |
| Django | python manage.py migrate --noinput |
Rails uses db:prepare rather than db:migrate because it also creates the database and
loads the schema when it is empty, which is exactly the state of a first deploy.
Set these commands in Build settings → Before activation, openship.json, or the
project API/SDK. A repository scan seeds a new project; existing projects keep their saved
commands, including an explicit empty list. The list is frozen on each deployment.
Each command gets the same environment variables as your start command and a 10-minute budget. It runs once per forward deployment attempt, in list order. Failure, timeout, or cancellation stops activation and leaves the previous application running.
On Docker, each command runs in a temporary container using the candidate image, the project's persistent mounts, and its connected-service networks. It publishes no ports and does not restart. Openship removes the container and its anonymous scratch volumes afterwards. On Bare, commands run in the staged release directory after persistent paths are linked, before the supervisor changes the running app. When a forward deploy reuses a retained Bare release, the command gets a temporary copy so it cannot edit the live application's files.
Use persistent storage for data
Only database and mounted-volume changes survive a Docker release container. Put
image-local cache generation, symlinks, and collected assets in the build or start
command. Store SQLite files under a declared persistent path, such as storage/, so the
release command and app use the same database on both Docker and Bare.
Runtime support
Release commands currently require a single-app Docker or Bare deployment, including portless workers. A database deployed as its own app and connected to this project keeps it single-app: the connection is injected as an environment variable, and the release command reaches the database over the network. Adding any enabled service to the same project, a database included, makes it a multi-service deployment instead, so a deploy with release commands fails before it builds. To run migrations against a database, deploy the database as its own app and connect it.
Full Compose/monorepo deployments, static sites, and runtimes without one-off command support fail before activation when commands are configured. Per-service release commands are not implemented. An action explicitly targeting only selected services skips the main app's release commands.
Rollbacks and database changes
Rollbacks skip release commands, including rollbacks that rebuild from Git. Other redeploys run the commands saved on that deployment, even when they reuse an existing image.
Use idempotent commands and schema changes compatible with the previous application. Rolling code back does not undo database changes, and a command may have made partial changes before failing. Openship does not promise a transaction across multiple commands.
Runtime roles
roles declares extra long-running processes that run beside your web app, from the
same built image: queue workers and schedulers. Each replica is its own container, so a
worker can scale without scaling the web app, and a scheduler runs exactly once.
{
"roles": [
{
"name": "queue",
"kind": "worker",
"command": "exec php artisan queue:work --tries=3",
"replicas": 2,
"stopGracePeriod": "90s"
},
{
"name": "scheduler",
"kind": "scheduler",
"command": "exec php artisan schedule:work"
}
]
}Roles are read from openship.json when a project is created from a repository scan.
Like release commands, an existing project
keeps the roles it has saved, so change those through the project API/SDK (roles; send
[] to remove them). The list is frozen on each deployment, and a project holds at most
20 roles.
Fields
| Field | Rules |
|---|---|
name | Required. A DNS label: lowercase letters, digits and dashes, starting and ending with a letter or digit, at most 63 characters. Unique within the project. |
kind | Required. worker or scheduler. web is rejected: the web process always comes from startCommand. |
command | Required, non-empty. Runs under sh -c in the same image as the web app. |
replicas | Optional, workers only. An integer from 1 to 100. Defaults to 1. Cannot be above 1 on a singleton. |
singleton | Optional. Run exactly one instance. A scheduler is always a singleton, so replicas above 1 on a scheduler is an error. |
health | Optional. { "kind": "process" } or { "kind": "exec", "command": "..." }. exec requires a non-empty command. http is rejected, because roles have no port. |
stopSignal | Optional. A signal name such as SIGQUIT, or a positive integer. When omitted, the image's STOPSIGNAL applies, or SIGTERM if the image sets none. |
stopGracePeriod | Optional. How long to wait after the stop signal before the process is killed: 90s, 1m30s, 10m, or a bare number of seconds. When omitted, the Docker daemon's default stop timeout applies. |
Where roles run
Roles run on single-app projects deployed to a Docker server. A deploy that declares roles is refused before the build starts, with no build, release command or container run, on:
- Openship Cloud
- direct (bare) hosts
- Kubernetes
- multi-service projects (Compose or monorepo)
- static sites
Moving a project that has roles to another server is refused as well.
What a role container gets
A role container starts from the same image as the web container and gets:
- the web container's environment variables, without
PORT - the project's persistent storage
- the same resource limits and restart policy
It publishes no port, gets no route and no network alias: only the web container answers to the app's network name. It joins the project network and the networks of any connected services before its process starts, so a worker can reach the database from its first instruction.
Deploy order
- The build runs.
- Release commands run.
- Roles start.
- The web container goes live.
Workers start beside the previous version's workers. The old ones keep running, and are removed only once the new web container is live.
Singletons (schedulers, or singleton: true) hand over stop-first. A new role continues
a previous one only when it keeps the name, effective singleton status (kind: "scheduler" or
singleton: true) and the command. For a continued singleton, the old container is stopped
and retained before the new one starts.
Continuation compares saved role fields. It does not detect changes in behavior caused by a new deployment image, environment, inherited image settings or application code behind an unchanged command.
A previous singleton that no new role continues is outgoing. A rename, a command change, a name swap or a change to a scalable worker each make a singleton outgoing. When the deploy also adds or changes a role, Openship stops and retains each outgoing singleton after the unchanged workers pass their startup check, and before any added or changed role starts. Otherwise an outgoing singleton keeps running until the new web container is live.
When unwinding a failed deploy, Openship attempts to remove the new containers and restore retained singletons. A continued singleton is restored only after its replacement is confirmed removed. An outgoing singleton is restored only after every added or changed role is confirmed removed.
This ordering uses the previous deployment's saved role definitions. Without them, only a
singleton that keeps its name hands over stop-first. Runtime failures or an interrupted
deploy can leave containers unresolved. Reconcile them, and restart the scheduler if needed,
before redeploying. A handover does not guarantee exactly-once scheduled execution, so keep
application-level guards such as Laravel's onOneServer().
Startup check
Every role container must stay up through the stabilization window, 15 seconds by
default. This check always runs for roles, whatever the project's readiness settings,
because a role has no port to probe. If the role declares an exec health command, it
runs once, after the window. A failed startup check fails the deploy and triggers the
rollback attempt described above. The startup check runs only during a deploy. It does not
continuously monitor role containers afterward.
Use exec so the stop signal reaches your process
The command runs under sh -c. Without exec, the shell stays as the main process and
the stop signal never reaches your worker, so it is killed after the grace period instead
of finishing its job. Write exec php artisan queue:work, not php artisan queue:work,
and set stopSignal and stopGracePeriod to match how long a job can run.
exec lets the stop signal reach your process. Whether the process exits depends on the
command. For example, Laravel 12.70.0's schedule:work, running as PID 1, kept ticking after
SIGTERM until it was killed at the end of the grace period. A longer grace period only delayed
scheduler replacement there.
Lifecycle
- Pausing, resuming and restarting the project act on its roles too.
- Deleting a deployment removes its role containers.
- Clearing a project's roles retires the running ones on the next deploy.
Not in the dashboard yet
Role containers are not shown in the dashboard, and no dashboard field edits roles: they
are set in openship.json or through the API. On the server, a role container is named
openship-<project slug>-<deployment id>-<role>-<replica>, so
docker logs <name> reads that role's output.
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). These declarations are active
automatically; a value saved in the dashboard or CLI for the same key overrides the repository default.
{
"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.
Full server capacity by default
The default is unlimited on both connected and managed Cloud servers. A container
can use the host's available CPU and memory, shared with the other applications on that
server. Select a tier or explicit values only when you want an additional container cap.
The Cloud subscription sets the server's allocation separately. Changing a project's resource tier never creates or resizes a server.
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": ".",
"buildArgs": { "NEXT_PUBLIC_API_URL": null },
"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
dependsOn takes service names such as ["db"], without a port. It controls container startup
order, not database availability during an image build. For ENOTFOUND db during next build,
see Database access during a Next.js build.
Top-level env is available to Dockerfile builds as well as running containers. Use
buildArgs when a service needs an explicit override or when you want the config to document
which Dockerfile arguments it consumes. A service's own env is runtime-only and is never shared
with sibling builds. Keep runtime-only credentials in service env where possible: Docker build
arguments are not a secret-storage mechanism and can be retained in image metadata or build cache.
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).
Each override must have a different normalized rootDirectory. Declaring a web
process and a worker at the same root is not a supported multi-app declaration.
openship config validate rejects duplicate roots; source scans report unmatched
apps and workspace overrides in the configuration diagnostics.
Create a monorepo through the repository import flow, which supplies the detected
app metadata. The API/SDK also accepts that metadata as monorepoApps. Setting
projectType: "monorepo" (or CLI project create --type monorepo) without it now
fails before creating a project. A local CLI import scans the workspace first.
For independent processes without containers, use separate projects pointed at
the required roots and start commands.
{
"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, runtimeVersion, runtimeImage, productionPaths, rootDirectory | Build settings |
port, productionMode, runtime | Runtime settings (hasServer, runtime isolation) |
releaseCommands | Build settings → Before activation |
roles | No dashboard field: set in openship.json or the project API |
volumes | Configuration → Persistent storage |
env | Environment variables (seeded as editable rows) |
domains | Public endpoints / custom domains |
routes | Reverse-proxy routing (routingConfig) |
resources | Optional container resource limits |
services | Compose services |
monorepo | Monorepo sub-apps + workspace |
See also
MCP
Let AI clients like Claude drive your Openship instance as tools, over the Model Context Protocol.
App catalog JSON
The complete field reference for an Openship App definition — the JSON that turns a set of containers into a one-click install, with generated secrets, a curated settings form, and a connection card.