Reference

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 schema

It'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"
}
SelectionBuilderRuntime
Not declared (unchanged default)php:8.4-clidunglas/frankenphp:1-php8.4-bookworm
8.3php:8.3-cli-bookwormdunglas/frankenphp:1-php8.3-bookworm
8.4php:8.4-cli-bookwormdunglas/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:

StackTypical release commands
Laravelphp artisan migrate --force
Railsbundle exec rails db:prepare
Djangopython 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

FieldRules
nameRequired. A DNS label: lowercase letters, digits and dashes, starting and ending with a letter or digit, at most 63 characters. Unique within the project.
kindRequired. worker or scheduler. web is rejected: the web process always comes from startCommand.
commandRequired, non-empty. Runs under sh -c in the same image as the web app.
replicasOptional, workers only. An integer from 1 to 100. Defaults to 1. Cannot be above 1 on a singleton.
singletonOptional. Run exactly one instance. A scheduler is always a singleton, so replicas above 1 on a scheduler is an error.
healthOptional. { "kind": "process" } or { "kind": "exec", "command": "..." }. exec requires a non-empty command. http is rejected, because roles have no port.
stopSignalOptional. A signal name such as SIGQUIT, or a positive integer. When omitted, the image's STOPSIGNAL applies, or SIGTERM if the image sets none.
stopGracePeriodOptional. 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

  1. The build runs.
  2. Release commands run.
  3. Roles start.
  4. 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.jsonDeploy wizard / API
framework, packageManager, *Command, outputDirectory, buildImage, runtimeVersion, runtimeImage, productionPaths, rootDirectoryBuild settings
port, productionMode, runtimeRuntime settings (hasServer, runtime isolation)
releaseCommandsBuild settings → Before activation
rolesNo dashboard field: set in openship.json or the project API
volumesConfiguration → Persistent storage
envEnvironment variables (seeded as editable rows)
domainsPublic endpoints / custom domains
routesReverse-proxy routing (routingConfig)
resourcesOptional container resource limits
servicesCompose services
monorepoMonorepo sub-apps + workspace

See also

On this page