# Guide ## Installation Shipyard runs on any Linux VPS with Docker installed. One script sets everything up. ### Prerequisites - Ubuntu 22+ / Debian 12+ / any modern Linux distro - Docker ≥ 24 and Docker Compose v2 (the script installs them if missing) - A domain name pointed at your server (optional but recommended for HTTPS) - Ports `80`, `443`, and `8080` open ### Run the install script ```bash curl -fsSL https://shipyard.trian.space/install.sh | sudo bash ``` The script will prompt you for: - **Domain** — e.g. `ship.example.com` (or leave blank to use the server IP) - **Enable HTTPS** — Yes sets up Let's Encrypt via Traefik automatically - **Admin email** — used for the Let's Encrypt certificate > [!NOTE] > All configuration is written to `/opt/shipyard/`. The stack runs as Docker Compose services. To restart: `cd /opt/shipyard && docker compose restart` ### What gets installed - **shipyard-backend** — Rust API server (Axum) - **shipyard-frontend** — SvelteKit dashboard - **PostgreSQL** — all platform state - **Traefik** — reverse proxy + automatic TLS - **RMQTT** — MQTT broker for real-time events > [!NOTE] > **Multi-node cluster support included** > > This installs Shipyard on a single VPS as the **manager node**. You can scale to a full Docker Swarm cluster at any time — no reinstall needed. Add more VPS machines as worker nodes with one script and Swarm distributes your workloads automatically across all machines. > > [Learn about Swarm & Multi-node](#swarm) ## Initial setup On first visit, Shipyard shows a setup wizard. 1. Open `http://` (or your domain if HTTPS is configured) 2. Create your **admin account** — enter an email and password 3. Create your first **organization** — give it a name and a URL slug 4. You are taken to the dashboard and are ready to deploy > [!TIP] > The setup wizard only runs once. After the admin account is created, the `/setup` route redirects to login. ## Manage organization Organizations are the top-level container for all projects and members. You can belong to multiple organizations. ### General settings Navigate to **Settings → General** to rename the organization or change its URL slug. > [!WARNING] > Changing the slug changes all URLs. Share the new link with your team after updating. ### Switching organizations Click the organization name in the top-left of the sidebar to open the org switcher. You can create a new organization from there as well. ## Members & roles Navigate to **Settings → Members** to manage who has access to the organization. ### Roles | Role | What they can do | |---|---| | Owner | Full access — billing, delete org, manage all settings | | Admin | Manage members, projects, services, and all settings | | Member | Deploy and manage services in assigned projects | | Viewer | Read-only access — can view logs and deployment status | ### Inviting members 1. Click **Invite member** 2. Enter the email address and choose a role 3. Optionally assign them to specific projects 4. Click **Send invitation** — the invitee receives an email with a join link > [!NOTE] > SMTP must be configured under **Settings → SMTP** for invitation emails to be delivered. ### Fine-grained permissions Owners and Admins bypass all permission checks automatically. For Members and Viewers, you can grant individual org-level permissions (e.g. `settings:write`, `keys:read`) and project-level access tiers (*View / Deploy / Manage*). Click a member → **Org Permissions** or **Project Access** to edit. See the full reference in [Permissions reference](#permissions) ## Permissions reference Shipyard uses a two-layer permission model. **Org-level** permissions control access to platform-wide features. **Project-level** tiers control what a member can do within a specific project. Permission strings follow the pattern `shipyard:::` for org-level and `shipyard::::` for project-level. Owners and Admins bypass all checks; these only apply to Members and Viewers. ### Org-level permissions These are granted directly to a member within the organization and are independent of any project. | Permission string | Label | What it allows | |---|---|---| | `settings:read` | View settings | Read org settings, main domain, and Traefik config | | `settings:write` | Edit settings | Modify org settings, SMTP, and domain config | | `members:read` | View members | See the member list and their roles | | `members:invite` | Invite members | Send invitations to new members | | `members:manage` | Manage members | Change roles, set permissions, and remove members | | `projects:read` | View all projects | Access any project in the organization | | `projects:write` | Manage projects | Create and delete projects | | `providers:read` | View providers | View connected Git provider accounts and webhook config (Settings → Providers) | | `providers:write` | Manage providers | Connect / disconnect GitHub, GitLab, Bitbucket and set webhook secrets | | `infra:read` | View infrastructure | View system metrics, swarm nodes, join tokens, and core service health | | `infra:write` | Manage infrastructure | Add/remove swarm nodes and modify cluster config | | `static:read` | View static server | View nginx static server configuration and site conf files (Settings → Static) | | `docker:read` | View Docker | Browse containers, services, volumes, and networks | | `docker:write` | Manage Docker | Prune containers and perform destructive Docker operations | | `deployments:read` | View deployments | View deployment history and status across all projects | | `deployments:write` | Manage deployments | Configure deployment parallelism and settings | | `smtp:read` | View SMTP config | View outgoing email configuration | | `smtp:write` | Manage SMTP config | Edit and test SMTP / email settings | | `audit:read` | View audit logs | Read organization activity history | | `keys:read` | View API keys | List API keys in the organization | | `keys:write` | Manage API keys | Create and revoke API keys | | `system:update` | Update Shipyard | Trigger platform updates and view update logs | > [!NOTE] > Full string stored in the database: `shipyard::settings:read`. The UI works with the suffix only (`settings:read`) and prepends the org ID at save time. ### Project-level permission tiers When assigning a member to a project you choose a tier — *View*, *Deploy*, or *Manage*. Each tier is additive: *Deploy* includes everything in *View*, and *Manage* includes everything in *Deploy*. | Tier | What it allows | Permission strings granted | |---|---|---| | View | Read-only access to services and deployments | `project:view` `service:view` | | Deploy | Trigger deployments, restarts, and rebuilds | *(View permissions, plus:)* `service:deploy` | | Manage | Create, edit, delete services; manage envs, domains, volumes, networks; access DB client | *(Deploy permissions, plus:)* `project:manage` · `service:write` · `service:delete` `env:read` · `env:write` · `domain:write` `volume:write` · `network:write` | > [!NOTE] > Full string format: `shipyard:::service:write`. These are stored as an array in `project_members.permissions` and checked by each backend endpoint. ### Which tier is needed for each feature | Feature | Minimum tier / permission | |---|---| | View services, logs, topology | View | | View deployment history | View | | Trigger deploy / redeploy | Deploy | | Restart / stop / start service | Deploy | | Create or edit a service | Manage | | Delete a service | Manage | | Add / edit environment variables | Manage | | Add / edit domains | Manage | | Manage volumes & networks | Manage | | Open DB Client | Manage (`service:write`) | | View org settings | Org: `settings:read` | | Edit org settings / SMTP / OAuth | Org: `settings:write` | | View infrastructure metrics | Org: `infra:read` | | Invite new members | Org: `members:invite` | | Change member roles / permissions | Org: `members:manage` | | View / create API keys | Org: `keys:read` / `keys:write` | | Trigger Shipyard platform update | Org: `system:update` | ## Manage projects Projects group related services together. Every service belongs to exactly one project. ### Creating a project 1. Click **New Project** in the sidebar 2. Enter a project name — the slug is generated automatically 3. Click **Create** ### Topology canvas Each project has a visual canvas showing all its services as nodes. Drag nodes to reposition them. Edges between nodes represent network connections between services. - Click a node to open the **Service Detail Panel** on the right - Status dots update in real time via MQTT — no page refresh needed - Right-click a node to access quick actions (deploy, stop, delete) ## Services & resources A service is a containerized workload. Shipyard supports several service types: | Type | Description | |---|---| | `docker` | Pull and run any Docker image | | `git` | Build from a Git repository (Dockerfile or Nixpacks) | | `static` | Serve static files via nginx — defaults to `nginx:alpine` | | `database` | Run a database image with preset options (Postgres, MySQL, Redis, etc.) | | `docker_compose` | Import a Compose file as managed services | | `sandbox_app` | Disposable coding sandbox — browser terminal, live preview, and one-click framework scaffolding (React, Vue, SvelteKit, Next.js, Nuxt, Astro) | > [!NOTE] > Sandbox apps are a different workflow from the other service types — see the dedicated [Sandbox Apps](https://shipyard.trian.space/docs/sandbox) guide for templates, the Custom scaffold-your-own mode, and the editor/terminal/preview UI. ### Creating a service 1. Inside a project, click **Add Service** 2. Choose the service type 3. Fill in the image, port, and replica count 4. Click **Create** — the service is created in *idle* state 5. Click **Deploy** to start it ### Resource limits In the **Service Detail Panel → Settings**, scroll to *Resource Limits*. Set a CPU limit (in cores, e.g. `0.5`) and a memory limit (in MB, e.g. `512`). These are enforced by Docker Swarm on each task. ### Replicas & scaling Adjust the **Replicas** field in Settings and hit Save. Swarm spreads replicas across available nodes. With a single node all replicas run on that node. ## Environment variables Click the **Env** button in the service header to open the environment variable manager. - Variables marked **Secret** are stored encrypted and masked in the UI - Changes take effect on the next deployment — existing containers are not restarted automatically - Use `__IMAGE__` as a special key to override the service image at deploy time > [!TIP] > Bulk-import variables by pasting a `.env` file format (KEY=value) into the import field. ## Domains & HTTPS Open a service → **Domains** tab to add a custom hostname. 1. Click **Add domain** 2. Enter the hostname (e.g. `api.example.com`) 3. Set the internal port the service listens on 4. Toggle **TLS** to enable Let's Encrypt — Traefik requests and renews the certificate automatically > [!NOTE] > The domain must resolve to your server's IP before TLS provisioning will succeed. Traefik uses HTTP-01 challenge by default. You can assign multiple domains to a single service. Each domain becomes its own Traefik router rule. ## Deploy from Git Create a service with type `git`, connect a repository, and Shipyard builds and deploys it on demand. ### Build process 1. Shipyard clones the repository at the configured branch 2. If a `Dockerfile` is found in the directory path, it builds with Docker Build 3. Otherwise it falls back to **Nixpacks** for automatic language detection 4. The built image is pushed to the local registry and deployed to Swarm ### Deployment steps Every deployment goes through numbered steps visible in the **Deploy** tab: 0. Validate config 1. Acquire image (clone + build, or pull) 2. Prepare networks 3. Prepare volumes 4. Configure domains 5. Create or update Swarm service 6. Write audit log Click any step to expand its log output. ## Deploy Docker image Create a service with type `docker` (or `database` / `static`), set the image tag, and click **Deploy**. ### Image digest pinning After pulling the image Shipyard resolves it to its `sha256` digest (`nginx@sha256:abc...`). Docker Swarm compares image refs by string — using the digest means Swarm detects a new image even when the tag (`:latest`) hasn't changed. This is how redeployments pick up the freshest image without a manual restart. ### Private registry In **Settings → Docker Image → Registry Credentials**, enter the registry URL, username, and password. These are stored as service-scoped secrets and injected into the Swarm service spec at deploy time. ### Database presets For `database` type services, the Settings tab shows one-click preset buttons: `postgres:16`, `mysql:8`, `redis:7-alpine`, `mongo:7`, `mariadb:11`. Clicking a preset fills the image field. ## Docker Compose import Navigate to a project → **Import Compose** to turn a `docker-compose.yml` into managed Shipyard services. 1. Paste or upload your Compose YAML 2. Set the root service name — used as the parent in the topology canvas 3. Click **Import** — Shipyard creates a service, network, and volume entry for each definition in the file 4. Deploy each imported service individually or trigger them in order > [!WARNING] > `build:` directives are not supported during import — use a pre-built image reference instead. ## Webhook triggers Trigger a deployment from any external system (GitHub Actions, GitLab CI, Docker Hub, a cron job) without using the dashboard. ### Getting the webhook URL 1. Open a service → **Settings** tab → scroll to *Webhook* 2. Click **Reveal token** to see your webhook URL 3. Copy the URL — it looks like `POST /api/webhooks/deploy/:service_id/:token` ### Triggering a deployment ```bash curl -X POST "https://ship.example.com/api/webhooks/deploy/SERVICE_ID/TOKEN" ``` A `200 OK` response means a deployment was queued. The response body contains the deployment ID: ```json { "data": { "message": "deployment queued", "deployment_id": "0194e012-3456-789a-bcde-f0123456789a", "source_ref": "deploy-webhook" } } ``` ### Updating the image tag on deploy Instead of always deploying `:latest` or whatever tag was previously configured on the service, you can pass a new image tag or a full image reference on each webhook trigger. #### Via query parameter (`?tag=` or `?image=`) ```bash # Update tag only (preserves repository/registry base, e.g. ghcr.io/org/app:v1.2.3) curl -X POST "https://ship.example.com/api/webhooks/deploy/SERVICE_ID/TOKEN?tag=v1.2.3" # Update full image reference curl -X POST "https://ship.example.com/api/webhooks/deploy/SERVICE_ID/TOKEN?image=ghcr.io/org/app:v1.2.3" ``` #### Via JSON body ```bash curl -X POST "https://ship.example.com/api/webhooks/deploy/SERVICE_ID/TOKEN" \ -H "Content-Type: application/json" \ -d '{"tag": "v1.2.3"}' ``` Docker Hub automated webhooks are also supported directly — when Docker Hub sends its `{"push_data": {"tag": "..."}}` payload, Shipyard automatically updates the tag and deploys the newly pushed image. ### GitHub Actions example ```yaml - name: Deploy to Shipyard with tag run: | curl -fsS -X POST \ "https://ship.example.com/api/webhooks/deploy/${{ secrets.SHIPYARD_SERVICE_ID }}/${{ secrets.SHIPYARD_WEBHOOK_TOKEN }}?tag=${{ github.ref_name }}" ``` ## Rollback Every successful deployment records the exact image digest that was running. You can roll back to any prior successful deployment. ### How to roll back 1. Open a service → **Deploy** tab 2. Find a past deployment with status success 3. Click the **↩** rollback button next to it 4. A new deployment is created — steps 0 and 1 are skipped (image is already known), the service is updated to the pinned digest > [!NOTE] > Only deployments created after the rollback feature was enabled have a recorded image digest. Earlier deployments show no rollback button. ## Audit log Navigate to **Settings → Audit** to view a full history of actions taken across the organization. Each entry records: - The **action** (e.g. `service.deploy`, `member.invite`, `service.delete`) - The **user** who performed it - The **resource** affected - The **IP address** of the request - The **timestamp** The log is paginated (50 entries per page). Use the Prev / Next buttons to navigate. ## Git providers Connect your GitHub, GitLab, or Bitbucket account to enable OAuth login and private repository access. Navigate to **Settings → Providers** to manage all connections. Requires the `providers:read` permission (or Admin/Owner role). ### Personal access token (PAT) 1. Open **Settings → Providers** and click **Connect** next to the provider 2. Paste a Personal Access Token with repository read scope 3. Click **Save Token** — Shipyard stores it encrypted and uses it for all deploys ### GitHub OAuth setup 1. Go to GitHub → **Settings → Developer settings → OAuth Apps → New OAuth App** 2. Set *Homepage URL* to your Shipyard domain 3. Set *Authorization callback URL* to `https://ship.example.com/auth/oauth/github/callback` 4. Copy the **Client ID** and **Client Secret** 5. In Shipyard, open **Settings → Providers**, click **Connect via OAuth** and complete the flow GitLab and Bitbucket follow the same pattern — create an OAuth application in each provider's developer settings and complete the OAuth flow in Shipyard. ### Webhook secret To verify push event signatures from your provider, set a **Webhook Secret** on the Providers page and configure the same value in each webhook you create on GitHub/GitLab. The incoming webhook URL is shown on the same page and in each service's detail panel. > [!NOTE] > Admins can grant `providers:read` / `providers:write` to Members so they can view or manage provider connections without full settings access. ## SMTP Configure SMTP so Shipyard can send invitation emails and notifications. ### Configuration 1. Go to **Settings → SMTP** 2. Toggle **Enable SMTP** 3. Enter host, port, username, password, and from address 4. Click **Save**, then **Send test email** to verify delivery | Field | Example | |---|---| | Host | `smtp.gmail.com` | | Port | `587` (STARTTLS) or `465` (SSL) | | Username | `you@gmail.com` | | Password | App password (not your account password) | | From address | `shipyard@example.com` | > [!NOTE] > SMTP settings are read from the database at send-time. Changing them takes effect immediately — no backend restart required. ## API keys API keys give programmatic access to Shipyard's API — useful for CI/CD pipelines or automation scripts. ### Creating a key 1. Go to **Settings → API Keys** 2. Click **New API key** 3. Give it a name, choose scopes, and optionally set an expiry date 4. Copy the key immediately — it is shown only once Keys look like `ship_...` and are authenticated via the `Authorization: Bearer ship_...` header. ```bash curl https://ship.example.com/api/orgs \ -H "Authorization: Bearer ship_your_key_here" ``` > [!WARNING] > Keys are stored as SHA-256 hashes. If you lose the key, revoke it and create a new one. ## Infra monitoring Navigate to **Settings → Infrastructure** to view real-time metrics about the host machine. ### Metrics streamed live - **CPU** — usage percentage with color-coded gauge (green / orange / red) - **Memory** — used vs total, with swap if present - **Disk** — per-mount usage and percentage - **Network** — cumulative RX/TX bytes per interface since boot (Docker internal interfaces are hidden) Metrics are delivered via Server-Sent Events (SSE) — the *Live* badge in the toolbar turns green when the stream is connected. Click **Reconnect** if the stream drops. ## Swarm & multi-node Shipyard runs on Docker Swarm. You can add additional VPS nodes to distribute workloads across machines. ### How Swarm works - Your Shipyard server is the **manager node** — it schedules services across the cluster - Additional VPS machines join as **worker nodes** — they run containers but have no scheduling control - Swarm automatically re-schedules replicas if a worker goes offline - Stateless services (APIs, web apps) work across nodes with zero extra config - Stateful services (databases) should be pinned to a specific node using placement constraints ### Adding a worker node On the new VPS, run the guided setup script: ```bash curl -fsSL https://shipyard.trian.space/worker-setup.sh | sudo bash ``` The script will: 1. Check and install Docker if needed 2. Prompt for the manager address and join token (find these in **Settings → Infrastructure → Join Tokens**) 3. Optionally configure Docker Hub, GitHub Container Registry (`ghcr.io`), or a custom registry 4. Join the swarm and verify the connection ### Join tokens Find the ready-to-copy `docker swarm join` commands in **Settings → Infrastructure → Join Tokens**. There are two token types: | Token type | Use for | |---|---| | worker | Standard nodes that run workloads — use this for most VPS additions | | manager | Nodes that also participate in scheduling — use for HA setups (3+ managers) | > [!WARNING] > Worker nodes only need Docker installed — no Shipyard stack, no Postgres, no Traefik. ### Viewing nodes The **Swarm Nodes** table on the infra page shows each node's hostname, role, status (*ready / down*), availability (*active / drain / pause*), address, and Docker engine version. ## Tenant isolation By default every service, sandbox and edge runtime joins one shared network, `platform_proxy`, so Traefik can reach it. That is fine when everyone on the Shipyard trusts each other. When unrelated organizations share it, turn on tenant isolation. ```bash # /opt/shipyard/.env SHIPYARD__DOCKER__TENANT_ISOLATION=true # optional, these are the defaults SHIPYARD__DOCKER__TRAEFIK_CONTAINER=shipyard-traefik SHIPYARD__DOCKER__BACKEND_CONTAINER=shipyard-backend ``` With it on: - Each organization gets its own overlay network, `platform-edge-`, created on its first deploy. Traefik and the backend are connected to every one of them; nothing else is. Tenants can still reach those two, which are public anyway. - Services, sandboxes and edge runtimes join their organization's network instead of `platform_proxy`. A container in one org can't reach another org's containers, or MQTT and the dashboard on `platform_proxy`. Postgres and Redis stay on the internal network as before. - Compose stacks that join `platform_proxy` as an external network are pointed at their org's network automatically. A stack that names another org's network, or uses `network_mode: host` or `container:…`, is rejected. - Existing services move on their next deploy. Existing edge runtimes move when they are recreated (remove the `shipyard-edge-*` service and it comes back on the new network). > [!WARNING] > A deploy fails if Traefik can't be connected to the org network, rather than shipping a service that would answer 502. Check the container name if you renamed it. ### Node pools To keep tenant workloads off the manager that runs Shipyard itself, label the nodes that should run apps and set the pool: ```bash docker node update --label-add pool=apps # /opt/shipyard/.env SHIPYARD__DOCKER__APP_NODE_POOL=apps ``` Services and edge runtimes then get the placement constraint `node.labels.pool==apps`. A service pinned to a node because it holds local volumes (a database) keeps its pin until its data is moved, so it never becomes unschedulable. ### Checking isolation `scripts/check-tenant-isolation.sh` starts a throwaway container on one org's network and tries to reach targets that must be unreachable from there: ```bash sudo scripts/check-tenant-isolation.sh \ platform-:3000 shipyard-mqtt:1883 shipyard-frontend:3000 shipyard-postgres:5432 ``` It exits non-zero if any target answers. ## Static server Shipyard includes an nginx-based static file server (`shipyard-nginx-static`) that hosts static sites deployed from the platform. Navigate to **Settings → Static** to inspect its current nginx configuration. Requires the `static:read` permission (or `infra:read` / Admin). ### Conf file viewer The left panel lists all `.conf` files inside the container's `/etc/nginx/conf.d/` directory. Select a file to view its full content in the right panel. Files are named after the service slug (e.g. `my-landing-page.conf`). > [!NOTE] > A conf file is only written when at least one domain is assigned to a static service. Services without a domain return 404 until a domain is attached. ## Docker resources Navigate to **Settings → Docker** to inspect and manage raw Docker resources on the host. ### Containers Lists all containers (running and stopped) with their image, status, and port bindings. Use **Prune stopped containers** to reclaim disk space. ### Services Lists all Swarm services currently deployed — name, image, running vs desired replicas, and exposed ports. ### Volumes Lists all named volumes with their driver, mountpoint, and scope. You can remove unused volumes from here. ### Networks Lists all Docker networks — driver, scope, subnet, and attached container count. Internal networks created by Shipyard for service isolation appear here. ## MQTT settings Shipyard includes an embedded RMQTT broker that powers real-time updates in the dashboard (service status, topology changes, deployment logs). Navigate to **Settings → MQTT** to view: - **Connected clients** — browser tabs and backend workers subscribed to the broker - **Active subscriptions** — per-client topic list - **Active topics** — topic names with subscriber counts ### Topic structure ```text title="topics" shipyard/services/{service_id}/status shipyard/services/{service_id}/containers shipyard/deployments/{deployment_id}/logs shipyard/topology/{project_id} ``` > [!NOTE] > You can subscribe to these topics from any MQTT client (e.g. MQTTX) using the broker credentials shown in Settings → MQTT. ## Traefik settings Traefik is the reverse proxy that routes incoming requests to your services and handles TLS certificates. Navigate to **Settings → Traefik** to inspect the generated configuration files. ### Static config The static configuration (`traefik.yml`) sets up the entry points and certificate resolver. It is generated once at install time. Key settings: ```yaml entryPoints: web: # port 80 — HTTP, redirects to HTTPS websecure: # port 443 — HTTPS certificatesResolvers: letsencrypt: acme: email: your@email.com storage: /letsencrypt/acme.json ``` ### Dynamic config Shipyard writes dynamic configuration (routers and services) automatically when you add or update a domain on a service. View the current dynamic config in **Settings → Traefik → Dynamic**. > [!TIP] > If a certificate isn't being issued, check that port 80 is open and the domain resolves to your server's IP. Traefik logs are visible via `docker logs shipyard-traefik-1`. ## Update profile Click your avatar or email in the top-right of the sidebar → **Profile**. ### Change email Enter a new email address and confirm your current password. The change takes effect immediately. ### Change password Enter your current password, then the new password twice. You will be logged out of all other sessions. ## Update Shipyard Navigate to **Settings → General** to check for and apply updates. ### One-click update 1. The current version is shown alongside the latest available release 2. Click **Update now** — Shipyard pulls the new images and restarts the stack 3. The update log streams in real time so you can follow the progress Alternatively, update manually from the server: ```bash cd /opt/shipyard docker compose pull docker compose up -d ``` > [!NOTE] > Database migrations run automatically on startup. No manual migration step is needed. ## Command palette The command palette gives you keyboard-driven access to any page, service, or action in Shipyard without touching the mouse. ### Opening the palette ⌘ + K on macOS Ctrl + K on Windows / Linux ### What you can do - Search services by name and jump to them directly - Navigate to any settings page - Trigger a deployment on a specific service - Switch organizations - Open the audit log, API keys, or member management Type to filter. Use ↑ ↓ to navigate results and Enter to select. Press Esc to close. --- Source: https://shipyard.trian.space/docs # API reference The Shipyard Open API lets you integrate with Shipyard from CI/CD pipelines, automation scripts, and external tools. All resources are scoped to your organization and authenticated with API keys. | Item | Value | |---|---| | Base URL | `https://your-shipyard/openapi/v1` | | Auth header | `Authorization: Bearer ship_…` | | Format | `Content-Type: application/json` | | Scopes | `read · deploy · write · admin` | ## Authentication All requests must include a valid API key. Keys are created in **Settings → API Keys** in the Shipyard dashboard. Every key starts with the prefix `ship_`. ### Sending the key Two headers are accepted — use whichever fits your client: ```http title="Option 1 — Authorization header (recommended)" Authorization: Bearer ship_your_key_here ``` ```http title="Option 2 — X-API-Key header" X-API-Key: ship_your_key_here ``` ### Full example ```bash curl https://ship.example.com/openapi/v1/projects \ -H "Authorization: Bearer ship_your_key_here" ``` > [!WARNING] > Keys are shown only once at creation time. Store them securely — if lost, revoke and create a new one. ## Scopes Each API key is issued with one or more scopes that control what it can do. Scopes are **not** additive — a key with `deploy` scope cannot read data unless it also has `read`. | Scope | What it allows | |---|---| | `read` | Read organizations, projects, services, and deployment history | | `deploy` | Trigger deployments on services | | `write` | Update service configuration (name, replicas) | | `admin` | Create and revoke API keys — elevated access | > [!NOTE] > For CI/CD pipelines that only trigger deployments, issue a key with the `deploy` scope only — no read or write access needed. ## Base URL & versioning All endpoints are prefixed with `/openapi/v1`. Replace `ship.example.com` with your Shipyard domain in every request. ```text title="base URL" https://ship.example.com/openapi/v1 ``` A `GET /openapi/v1/` request returns the API name and version — useful as a health check. ```bash curl https://ship.example.com/openapi/v1/ \ -H "Authorization: Bearer ship_…" ``` ```json title="200 OK" { "name": "Shipyard Open API", "version": "v1", "docs": "https://shipyard.trian.space/docs/api" } ``` ## Errors All error responses share a common JSON body: ```json title="error envelope" { "error": { "code": "NOT_FOUND", "message": "Service 'abc...' not found" } } ``` | HTTP status | code | When it occurs | |---|---|---| | `400` | `BAD_REQUEST` | Invalid or missing request body / query parameter | | `401` | `UNAUTHORIZED` | Missing, malformed, or expired API key | | `403` | `FORBIDDEN` | Key lacks the required scope, or resource belongs to another org | | `404` | `NOT_FOUND` | The requested resource does not exist | | `500` | `INTERNAL_ERROR` | Unexpected server error | ## Pagination List endpoints accept `page` and `per_page` query parameters. The response wraps the array in a `data` + `meta` envelope. | Parameter | Default | Max | Description | |---|---|---|---| | `page` | `1` | — | 1-based page index | | `per_page` | `20` | `100` | Items per page | ```json title="paginated response envelope" { "data": [ … ], "meta": { "total": 142, "page": 1, "per_page": 20 }, "request_id": "019…" } ``` > [!NOTE] > `request_id` is a UUIDv7 generated per request. Include it when reporting issues. ## Organizations An API key is tied to exactly one organization. These endpoints let you inspect it. ### GET /openapi/v1/orgs Requires scope: `read` Returns the organization this API key belongs to. ```bash curl https://ship.example.com/openapi/v1/orgs \ -H "Authorization: Bearer ship_…" ``` ```json title="200 OK" { "data": { "id": "018f…", "name": "Acme Corp", "slug": "acme", "created_at": "2024-01-15T09:00:00Z" }, "request_id": "019…" } ``` ### GET /openapi/v1/orgs/{org_id} Requires scope: `read` Convenience alias — resolves only if `org_id` matches the key's organization. Returns `403` otherwise. ## Projects ### GET /openapi/v1/projects Requires scope: `read` List all projects in the organization, newest first. **Query parameters** | Name | Type | Description | |---|---|---| | `page` | integer | Page index (default 1) | | `per_page` | integer | Items per page, max 100 (default 20) | ```bash curl "https://ship.example.com/openapi/v1/projects?page=1&per_page=20" \ -H "Authorization: Bearer ship_…" ``` ```json title="200 OK" { "data": [ { "id": "018f…", "org_id": "018e…", "name": "Production", "slug": "production", "created_at": "2024-03-01T12:00:00Z", "updated_at": "2024-03-10T08:30:00Z" } ], "meta": { "total": 3, "page": 1, "per_page": 20 }, "request_id": "019…" } ``` ### GET /openapi/v1/projects/{project_id} Requires scope: `read` Get a single project by ID. Returns `404` if it doesn't exist or belongs to another org. ## Services ### GET /openapi/v1/projects/{project_id}/services Requires scope: `read` List all services in a project, newest first. **Query parameters** | Name | Type | Description | |---|---|---| | `page` | integer | Page index (default 1) | | `per_page` | integer | Items per page, max 100 (default 20) | ```bash curl "https://ship.example.com/openapi/v1/projects/PROJECT_ID/services" \ -H "Authorization: Bearer ship_…" ``` ```json title="200 OK" { "data": [ { "id": "018f…", "project_id": "018e…", "name": "api", "slug": "api", "type": "git", "status": "running", "replicas": 2, "created_at": "2024-03-01T12:00:00Z", "updated_at": "2024-03-10T08:30:00Z" } ], "meta": { "total": 5, "page": 1, "per_page": 20 }, "request_id": "019…" } ``` ### GET /openapi/v1/services/{service_id} Requires scope: `read` Get a single service by ID. Returns `404` if it doesn't exist or belongs to another org. ### PATCH /openapi/v1/services/{service_id} Requires scope: `write` Update a service's name and/or replica count. At least one field must be provided. **Request body** | Field | Type | Required | Description | |---|---|---|---| | `name` | string | no | New display name for the service | | `replicas` | integer ≥ 0 | no | Desired replica count; 0 stops the service | ```bash curl -X PATCH https://ship.example.com/openapi/v1/services/SERVICE_ID \ -H "Authorization: Bearer ship_…" \ -H "Content-Type: application/json" \ -d '{"replicas": 3}' ``` ```json title="200 OK — updated service object" { "data": { "id": "018f…", "project_id": "018e…", "name": "api", "slug": "api", "type": "git", "status": "running", "replicas": 3, "created_at": "2024-03-01T12:00:00Z", "updated_at": "2024-03-10T09:00:00Z" }, "request_id": "019…" } ``` ## Deployments ### POST /openapi/v1/services/{service_id}/deploy Requires scope: `deploy` Trigger a new deployment for a service. The deployment runs asynchronously — poll `GET /deployments/:id` to track status. If the platform deployment parallelism limit is reached, the deployment is queued and returns `202 Accepted` with status `queued`. **Request body (optional)** | Field | Type | Required | Description | |---|---|---|---| | `source_ref` | string | no | Git ref, image tag, or label to record against the deployment (e.g. `main`, `v1.2.3`) | ```bash curl -X POST https://ship.example.com/openapi/v1/services/SERVICE_ID/deploy \ -H "Authorization: Bearer ship_…" \ -H "Content-Type: application/json" \ -d '{"source_ref": "v2.4.0"}' ``` ```json title="202 Accepted" { "data": { "deployment_id": "019…", "status": "running" }, "request_id": "019…" } ``` > [!TIP] > GitHub Actions example — deploy on every push to `main`: ```yaml title=".github/workflows/deploy.yml" - name: Deploy to Shipyard run: | curl -fsS -X POST \ -H "Authorization: Bearer ${{ secrets.SHIPYARD_API_KEY }}" \ -H "Content-Type: application/json" \ -d '{"source_ref":"${{ github.sha }}"}' \ "https://ship.example.com/openapi/v1/services/${{ vars.SERVICE_ID }}/deploy" ``` ### POST /api/webhooks/deploy/{service_id}/{token} Requires authentication: Webhook token in URL path (no API key header needed). Trigger a deployment via a webhook token. Supports updating the service's image tag dynamically via query parameter or JSON body. **Query parameters (optional)** | Name | Type | Description | |---|---|---| | `tag` | string | New tag to deploy (e.g. `v1.2.3`). Replaces the tag portion of the service image. | | `image` | string | Full new image reference (e.g. `ghcr.io/org/app:v1.2.3`). | **Request body (optional)** ```json { "tag": "v1.2.3", "image": "ghcr.io/org/app:v1.2.3" } ``` *Note:* Standard Docker Hub webhook payloads with `push_data.tag` are also automatically recognized. ```bash curl -X POST "https://ship.example.com/api/webhooks/deploy/SERVICE_ID/TOKEN?tag=v1.2.3" ``` ```json title="200 OK" { "data": { "message": "deployment queued", "deployment_id": "019…", "source_ref": "v1.2.3", "tag": "v1.2.3", "image": "myorg/myapp:v1.2.3" } } ``` ### GET /openapi/v1/services/{service_id}/deployments Requires scope: `read` List all deployments for a service, newest first. Paginated. ```json title="200 OK" { "data": [ { "id": "019…", "service_id": "018f…", "triggered_by": "api-key:ci-key", "source_ref": "v2.4.0", "status": "success", "created_at": "2024-03-10T09:00:00Z", "finished_at": "2024-03-10T09:02:35Z" } ], "meta": { "total": 12, "page": 1, "per_page": 20 }, "request_id": "019…" } ``` ### GET /openapi/v1/deployments/{deployment_id} Requires scope: `read` Get a single deployment by ID. Use this to poll for completion after triggering a deploy. | Status value | Meaning | |---|---| | `pending` | Created but not yet picked up | | `queued` | Waiting for parallelism slot | | `running` | In progress | | `success` | Completed successfully | | `failed` | Completed with an error | | `cancelled` | Cancelled before completion | ```bash title="poll for completion" # Poll until done (bash) DEPLOYMENT_ID="019…" while true; do STATUS=$(curl -fsS \ -H "Authorization: Bearer ship_…" \ "https://ship.example.com/openapi/v1/deployments/$DEPLOYMENT_ID" \ | jq -r '.data.status') echo "Status: $STATUS" [[ "$STATUS" == "success" || "$STATUS" == "failed" ]] && break sleep 5 done ``` ## API keys Manage API keys programmatically. All key endpoints require the `admin` scope. ### GET /openapi/v1/keys Requires scope: `admin` List all API keys for the organization. Paginated. The secret key value is never returned. ```json title="200 OK" { "data": [ { "id": "019…", "org_id": "018e…", "name": "ci-deploy", "key_prefix": "a1b2c3d4", "scopes": ["deploy"], "last_used_at": "2024-03-10T09:00:00Z", "expires_at": null, "created_at": "2024-02-01T00:00:00Z" } ], "meta": { "total": 2, "page": 1, "per_page": 20 }, "request_id": "019…" } ``` ### POST /openapi/v1/keys Requires scope: `admin` Create a new API key. The full key is returned only in this response — store it immediately. **Request body** | Field | Type | Required | Description | |---|---|---|---| | `name` | string | yes | Human-readable label for the key | | `scopes` | string[] | yes | One or more of: `read`, `deploy`, `write`, `admin` | | `expires_at` | ISO 8601 timestamp | no | Optional expiry; `null` means never expires | ```bash curl -X POST https://ship.example.com/openapi/v1/keys \ -H "Authorization: Bearer ship_…" \ -H "Content-Type: application/json" \ -d '{ "name": "ci-deploy", "scopes": ["deploy"], "expires_at": "2025-01-01T00:00:00Z" }' ``` ```json title="201 Created — key shown once" { "data": { "id": "019…", "name": "ci-deploy", "key": "ship_a1b2c3d4…", "key_prefix": "a1b2c3d4", "scopes": ["deploy"], "expires_at": "2025-01-01T00:00:00Z", "created_at": "2024-03-10T09:00:00Z" }, "request_id": "019…" } ``` ### DELETE /openapi/v1/keys/{key_id} Requires scope: `admin` Revoke an API key immediately. Returns `204 No Content` on success. You cannot revoke the key that is currently authenticating the request. ```bash curl -X DELETE https://ship.example.com/openapi/v1/keys/KEY_ID \ -H "Authorization: Bearer ship_…" ``` ## Object schemas ### Organization | Field | Type | Description | |---|---|---| | `id` | UUID | Unique identifier | | `name` | string | Display name | | `slug` | string | URL-safe identifier | | `created_at` | ISO 8601 | Creation timestamp | ### Project | Field | Type | Description | |---|---|---| | `id` | UUID | Unique identifier | | `org_id` | UUID | Owning organization | | `name` | string | Display name | | `slug` | string | URL-safe identifier | | `created_at` | ISO 8601 | Creation timestamp | | `updated_at` | ISO 8601 | Last modification timestamp | ### Service | Field | Type | Description | |---|---|---| | `id` | UUID | Unique identifier | | `project_id` | UUID | Owning project | | `name` | string | Display name | | `slug` | string | URL-safe identifier | | `type` | enum | docker · git · static · database · docker_compose | | `status` | string | Current container status (e.g. running, stopped) | | `replicas` | integer | Desired replica count | | `created_at` | ISO 8601 | Creation timestamp | | `updated_at` | ISO 8601 | Last modification timestamp | ### Deployment | Field | Type | Description | |---|---|---| | `id` | UUID | Unique identifier (UUIDv7 — sortable by time) | | `service_id` | UUID | Service that was deployed | | `triggered_by` | string | Initiator: api-key:name, webhook, or dashboard | | `source_ref` | string | Git ref, image tag, or label supplied at trigger time | | `status` | enum | pending · queued · running · success · failed · cancelled | | `created_at` | ISO 8601 | When the deployment was created | | `finished_at` | ISO 8601 · nullable | null while in progress | ### API key (list view) | Field | Type | Description | |---|---|---| | `id` | UUID | Unique identifier | | `org_id` | UUID | Owning organization | | `name` | string | Human-readable label | | `key_prefix` | string | First 8 hex chars — identifies the key without exposing it | | `scopes` | string[] | Granted scopes | | `last_used_at` | ISO 8601 · nullable | null if never used | | `expires_at` | ISO 8601 · nullable | null means never expires | | `created_at` | ISO 8601 | Creation timestamp | ## Scope matrix Quick reference — which scope each endpoint requires. | Endpoint | Method | Required scope | |---|---|---| | `/orgs` | `GET` | `read` | | `/orgs/:org_id` | `GET` | `read` | | `/projects` | `GET` | `read` | | `/projects/:id` | `GET` | `read` | | `/projects/:id/services` | `GET` | `read` | | `/services/:id` | `GET` | `read` | | `/services/:id` | `PATCH` | `write` | | `/services/:id/deploy` | `POST` | `deploy` | | `/services/:id/deployments` | `GET` | `read` | | `/deployments/:id` | `GET` | `read` | | `/keys` | `GET` | `admin` | | `/keys` | `POST` | `admin` | | `/keys/:id` | `DELETE` | `admin` | --- Source: https://shipyard.trian.space/docs/api # Edge functions Edge Functions let you run serverless TypeScript or JavaScript handlers alongside your other Shipyard services. Drop `.ts` files into a `functions/` directory in any Git repository — Shipyard detects them automatically on every push and deploys them to a Deno runtime scoped to your organization. | Item | Value | |---|---| | Invoke URL | `/fn//` | | Runtime | `Deno 1.x · TypeScript native` | | Deploy trigger | `Git push → auto-detect` | | Custom domain | `CNAME → edge runtime` | ## Quick start Three steps from zero to a running function. ### Step 1: Write a function Create a file inside `functions/` and export a default async function that accepts a `Request` and returns a `Response`. No framework, no boilerplate. ```ts title="functions/hello.ts" // functions/hello.ts export default async function handler(req: Request): Promise { const name = new URL(req.url).searchParams.get("name") ?? "world"; return new Response(`Hello, ${name}!`, { headers: { "Content-Type": "text/plain" }, }); } ``` ### Step 2: Connect a repo with an edge function group In the Shipyard dashboard, open your project → **Edge Functions** and create a new function group. Connect it to your Git repository. Shipyard will clone the repo and scan the `functions/` directory on every deploy. > [!NOTE] > You can attach one function group per repository branch. Multiple branches in the same repo can each have their own group. ### Step 3: Invoke your function Once deployed, your function is reachable at the path-based URL immediately — no DNS changes needed. ```bash curl https://your-shipyard.example.com/fn/my-org/hello?name=Claude ``` ```text title="response" Hello, Claude! ``` ## Repo structure Shipyard's function detector (`edge_fn_detector`) scans your repository after every deploy. It resolves functions in this order: 1. If `shipyard.json` defines an `entries` map, use those explicit paths. 2. Otherwise scan the configured `dir` (default `functions/`) for `.ts` and `.js` files. 3. A file is registered as a function only if it contains `export default`. 4. The function name is the filename without extension (e.g. `send-email.ts` → `send-email`). ```text title="example repo layout" my-repo/ ├── functions/ │ ├── hello.ts ← auto-detected (export default) │ ├── send-email.ts ← auto-detected │ └── utils.ts ← skipped (no export default) ├── shipyard.json ← optional overrides └── ... ``` > [!WARNING] > Files without `export default` are silently skipped — they will not appear in the deploy report and will not be invocable. Check your deploy report in the dashboard if a function is missing. ### Handler signature Every function must export a default async function with this exact signature. Shipyard passes the raw `Request` object from the Deno HTTP server. ```ts export default async function handler(req: Request): Promise { … } ``` ## shipyard.json Place a `shipyard.json` file at the root of your repository to override defaults. All fields are optional — an empty file or no file at all is valid. ```json title="shipyard.json" { "functions": { "dir": "functions", "runtime": "deno", "timeout_ms": 10000, "env": [ "DATABASE_URL", "STRIPE_KEY" ], "entries": { "send-email": "functions/mailer.ts", "resize-image": "functions/images/resize.ts" } } } ``` | Field | Type | Default | Description | |---|---|---|---| | `functions.dir` | string | `"functions"` | Directory to scan for function files | | `functions.runtime` | string | `"deno"` | Only `deno` is supported currently | | `functions.timeout_ms` | integer | `10000` | Per-request timeout in milliseconds (max 30 000) | | `functions.env` | string[] | `[]` | Allowlist of environment variable names to inject into the runtime. Variables not on this list are not accessible inside functions. | | `functions.entries` | object | `{}` | Explicit name → path mapping. When set, overrides directory scanning. Useful for functions nested in subdirectories. | > [!TIP] > Use `entries` when your functions live deeper in the repo (e.g. `src/edge/mailer.ts`) or when you want to control the public function name independently from the filename. ## Environment variables Set environment variables for your edge function group in the Shipyard dashboard under **Edge Functions → Settings → Environment**. Variables are encrypted at rest and injected into the Deno runtime on deploy. Inside your function, access them via `Deno.env.get()`. Only variables listed in `functions.env` in `shipyard.json` (or all variables if `env` is omitted) are passed through. ```bash title="accessing env vars" export default async function handler(req: Request): Promise { const dbUrl = Deno.env.get("DATABASE_URL"); // dbUrl is available because "DATABASE_URL" is in the env allowlist return new Response("ok"); } ``` > [!WARNING] > Never log or return the values of secrets in your response body. Edge function logs are visible to all members of your organization. ## Invoking functions Functions can be invoked via two URL patterns: the built-in path-based URL (always available) and a custom domain (optional, see next section). ### Path-based URL Every function is reachable under `/fn//` on your Shipyard domain — no configuration required. ```text title="URL format" https:///fn//[/extra/path][?query] ``` All HTTP methods are forwarded verbatim — GET, POST, PUT, PATCH, DELETE, and OPTIONS. Query parameters, request headers (excluding hop-by-hop headers), and the request body are passed through unchanged. ```bash title="GET with query params" curl https://your-shipyard.example.com/fn/my-org/hello?name=Claude ``` ```bash title="POST with JSON body" curl -X POST https://your-shipyard.example.com/fn/my-org/send-email \ -H "Content-Type: application/json" \ -d '{"to":"user@example.com","subject":"Welcome"}' ``` ### Error responses | Status | Meaning | |---|---| | `404` | Organization slug not found or function name not deployed | | `502` | Edge runtime is not running for this organization | | `504` | Function exceeded its `timeout_ms` limit | ## Custom domains Attach a custom domain to your edge function group so functions are reachable directly at your domain — without the `/fn//` prefix. ### Adding a domain 1. Go to **Edge Functions → Domains → Add Domain** in the dashboard. 2. Enter your custom hostname (e.g. `api.acme.com`). 3. Add a CNAME record pointing to the Shipyard edge runtime for your organization. 4. TLS is provisioned automatically via Let's Encrypt once DNS propagates. ```text title="CNAME record" # CNAME your custom domain to the Shipyard edge runtime api.acme.com. IN CNAME shipyard-edge-.example.com. ``` Once the domain is active, functions are invoked at the root path — the function name becomes the first path segment. ```bash title="invoke via custom domain" curl https://api.acme.com/send-email ``` > [!NOTE] > Custom domain routing is handled by Traefik. Each domain gets its own dynamic Traefik configuration rule (`Host(...)`) that routes all traffic directly to the edge runtime — bypassing the `/fn/` path entirely. ### Removing a domain Removing a domain from the dashboard immediately removes the Traefik routing rule. Existing DNS records are not affected — you must remove the CNAME yourself if you no longer need it. ## Deployment history Every time you click **Deploy** in the dashboard, Shipyard creates an immutable deployment record for each function it detects. You can browse the full history, view the exact code from any past deployment, and restore any previous version in one click. ### Deployment statuses | Status | Meaning | |---|---| | live | Currently serving traffic — the most recent successful deployment | | pending | Deploy in progress (git clone + function scan running) | | error | Deploy failed (check the error message in the history row) | | rolled_back | Superseded by a newer deployment or a manual rollback | ### Viewing code from a past deployment In the dashboard, expand any function in the Edge Functions panel and click **History**. Each row shows the commit SHA, deploy time, and status. Click **View** on any row to open a read-only code viewer showing the exact code bundle from that deployment. ### Restoring a previous deployment (rollback) Click **Restore** on any past deployment row to make it live again. Shipyard copies the code bundle from that snapshot into a new deployment record with status `live` and marks the previous live deployment as `rolled_back`. The rollback takes effect immediately — no rebuild or git clone required. > [!TIP] > Rollbacks are instant because the code bundle is stored alongside each deployment record — no git history access is needed at restore time. ### Deploy report After every deploy, the dashboard shows a summary of what happened: | Field | Meaning | |---|---| | `deployed` | Functions that were detected and successfully deployed | | `skipped` | Files that were scanned but had no `export default` | | `failed` | Files that were detected but failed to compile or load | | `deleted` | Functions present in the previous deploy but no longer in the repo | ## Limits & quotas Limits are enforced per organization. Contact support to request higher limits on paid plans. | Limit | Free | Pro | |---|---|---| | Edge function groups | 1 | Unlimited | | Functions per group | 5 | 100 | | Custom domains per group | 0 | 10 | | Deployment history retained | 10 per function | Unlimited | | Request timeout (`timeout_ms` max) | 10 000 ms | 30 000 ms | | Max request body size | 1 MB | 10 MB | | Concurrent requests per runtime | 50 | 500 | > [!WARNING] > The free plan does not support custom domains. Upgrade to Pro to attach your own hostname to an edge function group. --- Source: https://shipyard.trian.space/docs/edge-functions # Sandbox apps A Sandbox App is a disposable, containerized coding environment with a real terminal, a file editor, and a live preview URL — no local setup required. Pick a framework template and Shipyard scaffolds it with the official CLI, installs dependencies, and boots the dev server automatically. Or start from a blank **Custom** container and scaffold whatever you want by hand. | Item | Value | |---|---| | Preview URL | `preview-.your-domain.dev` | | Idle timeout | `20 minutes (default)` | | Templates | `4 basic + 11 framework variants` | | Terminal | `Real shell, via WebSocket` | ## Quick start 1. Inside a project, click **New App** and choose a Sandbox App 2. Pick a template — a framework (React, Vue, SvelteKit, Next.js, Nuxt, Astro), a basic runtime (Node, Python, Static), or **Custom** for a blank container 3. Give it a name — the slug is generated automatically — and click **Create App** 4. Open the app to reach the editor: on first boot, Shipyard scaffolds the template, installs dependencies, and starts the dev server 5. Edit files, use the terminal, and watch the **Preview** tab update live > [!NOTE] > A framework template's first boot runs a real `npm create …` scaffold and install over the network — it takes noticeably longer than a plain Node sandbox's first start. Subsequent stop/start cycles are fast since the scaffolded project already lives on the sandbox's persistent volume. ## Basic templates Four built-in starting points beyond the framework gallery below. Each resolves to a fully-declared runtime at creation time — nothing to detect. | Template | Base image | Install | Dev command | Port | |---|---|---|---|---| | **Node** | `node:22-alpine` | npm install | `npm run dev` | 3000 | | **Python** | `python:3.12-slim` | pip install -r requirements.txt | `python app.py` | 8000 | | **Static** | `nginx:alpine` | — | `nginx (static)` | 8080 | | **Custom** | `node:22-alpine` | — | `sleep infinity (idle)` | — | > [!TIP] > **Custom** boots an idle container (`sleep infinity`) with nothing installed yet — see [Scaffold your own](#custom-mode) for the full workflow. ## Framework gallery Each framework template invokes that framework's own official, non-interactive scaffolder on first boot — the same command you'd run on your own machine. Dependencies install as a separate, explicit step, then the dev server starts bound to `0.0.0.0` and the sandbox's assigned port so the preview can actually reach it. | Framework | Variants | Scaffolder | Dev server default port | |---|---|---|---| | **React** | JS / TS | `npm create vite@latest` | 5173 | | **Vue** | JS / TS | `npm create vite@latest` | 5173 | | **SvelteKit** | JS / TS | `npx sv create` | 5173 | | **Next.js** | JS / TS | `npx create-next-app@latest` | 3000 | | **Nuxt** | TS only | `npx nuxi@latest init` | 3000 | | **Astro** | JS / TS | `npm create astro@latest` | 4321 | > [!NOTE] > Nuxt ships as a single template — its own scaffolder defaults to TypeScript with no meaningful plain-JS mode in current tooling, so there's no separate JS variant to offer. > [!WARNING] > Astro's own tooling requires a newer Node runtime than the other frameworks, so Astro sandboxes run on a different base image under the hood. This is handled automatically — nothing to configure. ## Scaffold your own (Custom mode) Choose the **Custom** template to skip the gallery entirely. It boots an empty, idle container — nothing installed, no dev server running — so you can scaffold exactly what you want by hand from the Terminal tab. ### The workflow ```bash title="inside the sandbox terminal" # Inside the sandbox's Terminal tab npx create-react-app . # or: npm create vite@latest . -- --template svelte # or literally anything else — it's just a shell # Then, from the app's canvas panel: # Stop -> Start # Shipyard re-detects the real stack and boots its dev server. ``` Shipyard only re-checks what's in the container when the sandbox restarts — it never watches the filesystem while it's running. That's why the last step is always **Stop**, then **Start**, from the app's canvas panel: that recreates the container and re-runs stack detection against whatever you scaffolded. > [!NOTE] > Until a real project is detected, the editor shows a banner: *"No project detected yet — scaffold one in the Terminal below, then stop and start this sandbox from its app panel on the project canvas to apply it."* The container stays alive and the terminal stays usable the whole time. ### Prefer to declare it yourself? A Custom sandbox starts with a starter file at `/app/shipyard.json.example`. Rename it to `shipyard.json`, fill in your own values, and restart — Shipyard trusts an explicit manifest over guessing from files on disk. See the [manifest format](#manifest) below. ## shipyard.json manifest Every sandbox — template-created or Custom — can be pointed at an explicit manifest instead of relying on auto-detection. Drop a `shipyard.json` file in the project root and restart the sandbox. ```json title="shipyard.json" { "app": { "runtime": "node", "install": "npm install", "dev": "npm run dev", "port": 3000 } } ``` | Field | Meaning | |---|---| | `runtime` | Label only — informational, shown in the UI | | `install` | Shell command run once before the dev server starts | | `dev` | Shell command that starts the dev server — must bind `0.0.0.0`, not just `localhost` | | `port` | The port the dev server listens on — the preview route is wired to this exact port | > [!WARNING] > A manifest takes priority over every file-presence heuristic (`package.json`, `requirements.txt`, `index.html`, etc.). If `shipyard.json` exists, its values are used as-is — Shipyard will not second-guess it. ## Editor & files Opening a sandbox app lands you on its editor page: a file tree on the left, a code editor in the center, and Preview / Terminal alongside it. ### File tree - Click any file to open it in the editor - Create new files directly from the tree — folders are created automatically from the path you type - Changes autosave as you type; a save indicator confirms when a write completes ### Hot reload Saving a file that the running dev server watches (which is every framework template — that's exactly what `npm run dev` is for) reflects in the **Preview** tab automatically, the same as running the dev server on your own machine. ### Preview sizing The Preview tab includes desktop and mobile width presets, plus a free-drag handle, so you can sanity-check responsive layouts without leaving the editor. ## Terminal A real shell into the running sandbox container, always available below the Editor/Preview panel — not a separate tab you have to switch to. Use it to run scaffolding CLIs, install extra packages, inspect logs, or debug directly. > [!NOTE] > The terminal connects over a WebSocket authenticated with a short-lived (5-minute) token minted specifically for that connection — it is re-issued automatically, you never have to think about it. ## Live preview Every sandbox gets its own preview URL, routed through Traefik straight to the running dev server: ```text title="preview URL format" https://preview-a1b2c3d4.shipyard-apps.dev ``` The preview link works for anyone with the URL — no Shipyard login required — so you can share a running sandbox with a teammate or a client directly. > [!TIP] > Visiting a stopped sandbox's preview URL automatically wakes it back up. The first request after a cold start waits for the container and dev server to come up before the page loads. ## Idle timeout & quota Sandboxes are meant to be disposable, not long-running services — Shipyard stops them automatically when nobody's using them, and caps how many can run at once per organization. ### Idle timeout While the editor tab is open it sends a periodic heartbeat. A sandbox with no heartbeat for **20 minutes** (the default; configurable per deployment) is automatically stopped — its files and data are untouched, only the running container and dev server are torn down. Opening the app again starts it right back up. ### Concurrent sandbox limit Each plan sets a maximum number of sandboxes that may be *running or starting* at the same time across the whole organization. Stop an unused sandbox — or wait for one to idle out — to free up a slot. > [!NOTE] > Stopping a sandbox doesn't delete it. Files on its volume, its assigned template, and its preview URL are all preserved — starting it again picks up exactly where you left off. --- Source: https://shipyard.trian.space/docs/sandbox # Container registry Shipyard includes a built-in OCI-compatible container registry. Push images from your local machine or CI pipeline, then deploy them to your Swarm services with zero external dependencies. The registry speaks the standard Docker Distribution API — any tool that works with Docker Hub or GHCR works here too. It is not limited to Docker images: the same registry stores **static site bundles** (and edge function bundles) as OCI artifacts, so a CI pipeline can build a site once, push it, and have Shipyard deploy it — see [Static site bundles](#static-sites). | Item | Value | |---|---| | Registry URL | `registry.your-domain.com` | | Auth | `API key (ship_...)` | | Protocol | `OCI Distribution Spec v1.1` | | Image path | `org/project/repo:tag` | ## Quick start 1. Create an API key with `registry:manage` scope in **Settings → API Keys** 2. Run `docker login registry.your-domain.com -u anyuser -p ship_xxx` 3. Tag your image: `docker tag myapp registry.your-domain.com/acme/myproject/myapp:latest` 4. Push: `docker push registry.your-domain.com/acme/myproject/myapp:latest` 5. In a Shipyard service, set the image to the full registry URL and deploy ## API keys The registry uses Shipyard API keys for authentication — not separate registry credentials. Keys start with `ship_` and are scoped to control exactly what the holder can do. | Scope | Allows | |---|---| | `registry:view` | Pull images only — read-only access to all repositories in the organization | | `registry:manage` | Pull *and* push — full read/write access; also includes `registry:view` | ### Creating a key 1. Go to **Settings → API Keys** 2. Click **New API key** 3. Give the key a name (e.g. *CI Push* or *Read-only pull*) 4. Select `registry:manage` (push + pull) or `registry:view` (pull only) 5. Optionally set an expiry date 6. Copy the key immediately — it is shown only once > [!WARNING] > Keys are stored as SHA-256 hashes. If you lose a key, revoke it and create a new one. Never commit keys to source control — use CI/CD secrets instead. ## docker login The registry implements the Docker token authentication spec. Docker automatically negotiates a short-lived JWT when you run `docker login` — you never interact with the token directly. ### Login command ```bash docker login registry.your-domain.com \ -u anyuser \ -p ship_xxxxxxxxxxxxxxxxxxxx ``` > [!NOTE] > The **username is ignored** — pass any non-empty string. Only the password (your `ship_...` API key) is used for authentication. ### How it works 1. Docker sends `GET /v2/` to check the registry 2. The registry returns `401 Unauthorized` with a `WWW-Authenticate` header pointing to `/auth/registry/token` 3. Docker sends Basic auth (username + API key) to the token endpoint 4. Shipyard validates the API key and returns a signed 15-minute JWT 5. Docker uses the JWT for all subsequent push and pull requests > [!TIP] > Credentials are cached in `~/.docker/config.json`. You only need to run `docker login` once per machine unless you rotate the key. ## Image naming Images are namespaced under your organization slug, then a project slug, then a repository name. This mirrors the Shipyard project hierarchy and prevents name collisions between organizations. ```text title="format" # Format registry.your-domain.com///: # Examples registry.your-domain.com/acme/backend/api:latest registry.your-domain.com/acme/backend/api:v1.2.3 registry.your-domain.com/acme/frontend/web:sha-abc1234 ``` | Segment | What it maps to | Example | |---|---|---| | `org-slug` | Your organization's URL slug | `acme` | | `project-slug` | The project containing the service | `backend` | | `repo` | Arbitrary repository name — usually the service name | `api` | | `tag` | Image tag — `latest`, a version, or a git SHA | `v1.2.3` | > [!NOTE] > The org and project slugs are visible in the Shipyard URL when you navigate to a project: `https://ship.example.com/orgs/acme/projects/backend` ## Push & pull ```bash # Tag a local image docker tag myapp:latest registry.your-domain.com/acme/backend/api:latest # Push docker push registry.your-domain.com/acme/backend/api:latest # Pull docker pull registry.your-domain.com/acme/backend/api:latest ``` ### Deploying a pushed image After pushing, point a Shipyard service at the full registry URL. The simplest way is to set the **Image** field in the service settings to the full registry path and click **Deploy**. You can also override the image per-deployment via the special environment variable `__IMAGE__`: ```text title="service env vars" # In a service's environment variables, override the image at deploy time __IMAGE__=registry.your-domain.com/acme/backend/api:v1.2.3 ``` ### Private registry credentials in Swarm For services pulling from the registry at deploy time, Shipyard automatically injects the registry credentials as a Docker Swarm secret so worker nodes can pull without separate `docker login` calls. No extra configuration is needed on worker nodes. ## CI/CD integration Store your API key as a CI secret and use it to authenticate during your build pipeline. Combine with a webhook trigger to redeploy after every successful push. ### GitHub Actions Add `SHIPYARD_REGISTRY_KEY` to your repository secrets, then: ```yaml title=".github/workflows/build.yml" name: Build and push to Shipyard registry on: push: branches: [main] jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Log in to Shipyard registry uses: docker/login-action@v3 with: registry: registry.your-domain.com username: ci password: ${{ secrets.SHIPYARD_REGISTRY_KEY }} - name: Build and push uses: docker/build-push-action@v5 with: push: true tags: registry.your-domain.com/acme/backend/api:latest ``` ### GitLab CI ```yaml title=".gitlab-ci.yml" variables: IMAGE: registry.your-domain.com/acme/backend/api before_script: - docker login $CI_REGISTRY -u ci -p $SHIPYARD_REGISTRY_KEY build: script: - docker build -t $IMAGE:$CI_COMMIT_SHORT_SHA . - docker push $IMAGE:$CI_COMMIT_SHORT_SHA ``` > [!TIP] > After pushing a new image tag, trigger a Shipyard service deployment automatically with a [webhook trigger](https://shipyard.trian.space/docs#webhooks) — no manual clicking required. ## Static site bundles Any OCI artifact pushed with the `org.shipyard.artifact.kind` manifest annotation is stored as that kind instead of a Docker image. A static site is pushed as a single **zip** layer of your build output with `kind=static_bundle`; a static site service bound to that `repo:tag` deploys it straight from the registry — no build step on the server. | Kind | Annotation value | Content | |---|---|---| | Docker image | *(none — the default)* | Standard image layers | | Static site | `static_bundle` | One `.zip` layer of the built site | | Edge function | `edge_function` | One JS bundle layer | Optional annotations are recorded as metadata and shown in the registry browser: | Annotation | Example | |---|---| | `org.shipyard.framework` | `vite` | | `org.shipyard.build_command` | `npm run build` | | `org.shipyard.output_dir` | `dist` | ### Push with ORAS `docker push` only pushes images, so use [ORAS](https://oras.land) — the standard CLI for pushing arbitrary OCI artifacts. It logs in with the same `ship_` API key. ```bash # 1. Log in (username is ignored, password is a key with registry:manage) oras login registry.your-domain.com -u ci -p ship_xxxxxxxxxxxxxxxxxxxx # 2. Zip the *contents* of your build output (index.html at the zip root) npm run build (cd dist && zip -qr ../site.zip .) # 3. Push it as a static_bundle oras push registry.your-domain.com/acme/frontend/web:v1.2.0 \ --artifact-type application/vnd.shipyard.static-bundle \ --annotation "org.shipyard.artifact.kind=static_bundle" \ --annotation "org.shipyard.framework=vite" \ site.zip:application/zip ``` > [!NOTE] > The bundle must be a **.zip**. Zipping the output folder itself (`dist/index.html` inside the zip) also works — a single wrapping directory is unwrapped automatically, and a `shipyard.json` with `build.output` at the zip root is honoured. > [!TIP] > Every push of a version tag also moves the `latest` tag to the same bundle. Bind a site to `latest` for continuous deploys, or to a fixed tag to pin a release. ### Deploy a bundle 1. In a project, add a **Static site** and choose **Source → Shipyard Artifactory** 2. Pick the bundle (`namespace/repo:tag`) from the registry 3. Leave **Auto-deploy on push** checked to redeploy whenever that tag is pushed again 4. Click **Deploy** for the first release Each deploy fetches the newest bundle pushed to the bound tag, extracts it, and serves it — the previous version stays on disk for instant rollback. Auto-deploy can be toggled later from the service's settings. ### GitHub Actions Add `SHIPYARD_REGISTRY_KEY` (a `registry:manage` API key) to your repository secrets. This workflow builds the site, pushes it tagged with the commit SHA (which also updates `latest`), and — with auto-deploy on — Shipyard rolls it out: ```yaml title=".github/workflows/deploy-site.yml" name: Deploy site to Shipyard on: push: branches: [main] env: REGISTRY: registry.your-domain.com REPO: acme/frontend/web jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 20 cache: npm - run: npm ci && npm run build - name: Package bundle run: cd dist && zip -qr ../site.zip . - uses: oras-project/setup-oras@v1 - name: Log in to Shipyard registry run: echo "${{ secrets.SHIPYARD_REGISTRY_KEY }}" | oras login $REGISTRY -u ci --password-stdin - name: Push static bundle run: | oras push $REGISTRY/$REPO:sha-${GITHUB_SHA::7} \ --artifact-type application/vnd.shipyard.static-bundle \ --annotation "org.shipyard.artifact.kind=static_bundle" \ --annotation "org.shipyard.build_command=npm run build" \ --annotation "org.shipyard.output_dir=dist" \ site.zip:application/zip ``` To ship only on releases instead, trigger on `push: tags: ['v*']`, push `$REPO:${GITHUB_REF_NAME}`, and bind the site to `latest`. ## Deleting tags Remove a single tag without touching the rest of the repository. In the dashboard, open **Registry → namespace → repository** and click the trash icon on the tag's row. **Delete repository** is still available to remove every tag at once. The same action is available over the API with a session token or an API key that has `registry:manage`: ```bash curl -X DELETE \ -H "Authorization: Bearer ship_xxxxxxxxxxxxxxxxxxxx" \ "https://ship.example.com/api/orgs//registry/namespaces//repos/web/tags/v1.2.0?kind=static_bundle" ``` | Behaviour | Detail | |---|---| | `kind` query param | Optional. A tag is unique per kind, so omit it to delete every kind under that tag name | | Other tags | Kept — deleting `v1.2.0` does not remove `latest`, even if both point at the same digest | | Bound services | Returns `409 Conflict` while a service's artifact source uses the tag; re-point the service first | | Storage | Manifests and blobs are left for garbage collection, since other tags may reference them | Platform admins can delete tags in any organization via `DELETE /api/admin/registry/namespaces//repos//tags/`. ## Registry setup The registry is bundled with Shipyard and enabled by default. To make it reachable via its own hostname (recommended), set one environment variable in your `/opt/shipyard/.env` file: ```env title="/opt/shipyard/.env" SHIPYARD__REGISTRY__HOSTNAME=registry.your-domain.com ``` ### DNS setup Point your registry subdomain at the same server IP as your main Shipyard domain. An **A record** (or CNAME to your main domain) is sufficient: | Record type | Name | Value | |---|---|---| | `A` | `registry` | Your server's public IP | TLS is provisioned automatically by Traefik via Let's Encrypt — no manual certificate management needed. > [!NOTE] > Restart the Shipyard stack after changing `.env`: `cd /opt/shipyard && docker compose restart shipyard-backend` ## Traefik routing Shipyard auto-generates a Traefik dynamic configuration on every startup. The registry gets its own router rule that adds the `/registry` path prefix before forwarding to the backend — so the standard OCI paths (`/v2/…`) work transparently at the registry hostname. ```yaml title="generated traefik config (excerpt)" http: routers: shipyard-registry: rule: "Host(`registry.your-domain.com`)" entryPoints: [websecure] service: shipyard-backend middlewares: - shipyard-registry-prefix tls: certResolver: letsencrypt middlewares: shipyard-registry-prefix: addPrefix: prefix: "/registry" ``` This means `registry.your-domain.com/v2/…` is routed to the backend as `/registry/v2/…`. The Traefik config file is re-written on every backend startup, so it is self-healing — if the file is lost the routes are restored automatically. > [!NOTE] > View the current generated config in the dashboard under **Settings → Traefik → Dynamic**. --- Source: https://shipyard.trian.space/docs/registry # Nomi integration This page is written for the coding agent that implements Nomi's side of the Shipyard integration. Read all of it before writing code. Every request and response shape here matches the server; when this page and your assumptions disagree, this page wins. Nomi is an agent orchestrator that builds web apps (SvelteKit, Next.js, React, Vue; Postgres with Drizzle). Shipyard is where those apps are deployed and published. Nomi does development in its own containers; Shipyard provides the development database, runs published environments, owns their web addresses, and reports everything back through webhooks. | Item | Value | |---|---| | Base URL | `https:///openapi/v1/integration` (also reachable as `/integration`, `/integrations` and the site root; use the first) | | Auth | `Authorization: Bearer ship_int_<64 hex>` (see [Credentials](#credentials)) | | Content type | `application/json`, except publish (`multipart/form-data`) | | Response envelope | `{ "data": ..., "error": null }` or `{ "data": null, "error": { "code", "message" } }` | ## Quick start for the coding agent If you read nothing else, this is the whole integration in order: 1. `PUT /orgs/{org}` when Nomi creates an organization. 2. `PUT /orgs/{org}/apps/{app}` with `"dev_database": true` when a project is created. Keep the two `*_url_ref` values. 3. Server-side only: reveal `app_url_ref`, set it as `DATABASE_URL` in the dev container. 4. On publish: zip the source, build the [manifest](#manifest), `POST .../envs/{env}/publish`. You get `202` and a `publish_id`. **The app is not live yet.** 5. Wait for the `publish.succeeded` webhook (or poll `GET .../publishes/{publish_id}` every 3 s). Only then show the user `urls[0]`. 6. If it failed, `GET .../deployments/{deployment_id}` returns `failure` with the step, phase, message and log tail. Fix the code, publish again. See [the fix loop](#fix-loop). 7. Nomi picks each environment's full hostname (e.g. `todo.nomi.app`) and sends it as `domain.hostname` in the manifest. User wants another address: `GET /orgs/{org}/domains/check?hostname=...` as they type, then `PUT .../envs/{env}/domain`. See [Choosing a domain](#domains). 8. `POST .../duplicate` for another environment, `DELETE .../apps/{app}` to terminate. Before you ship, run the readiness checker against a real Shipyard (it creates and deletes a throwaway org): `python3 scripts/check-nomi-integration.py` in the Shipyard repo. See [Verifying a Shipyard](#verify). ## Credentials Three kinds of credential work on this API. Each only reaches the organizations it owns. | Credential | Reaches | Can do | |---|---|---| | Integration key `ship_int_...` | Orgs created through it (`PUT /orgs/{org}`), plus orgs a superadmin linked to the integration | Everything in this page, within its scopes (`manage`, `secrets:reveal`) | | Org API key `ship_...` (created in the dashboard) | Only its own org | Same routes by `manage`-equivalent scopes: needs `write` or `admin`. Cannot create orgs, change plans, manage webhooks or delete the org | | User access token (JWT) | Orgs where the user is a member | Same as an org key by role. Cannot create orgs, change plans, manage webhooks or delete the org | Use an integration key for everything Nomi's server does. Integration keys never see an org they did not create, so an org that already existed in Shipyard (made in the dashboard) is invisible until a superadmin links it: ```bash curl -X PUT https:///api/admin/integrations//orgs/ \ -H "Authorization: Bearer " -H "Content-Type: application/json" \ -d '{"external_id":"org_2Nf8sK"}' # your id for it; DELETE the same path to unlink ``` ## Rules Follow these without exception. 1. **Secret values never enter a model's context.** Not in prompts, tool results, logs shown to the agent, or files the agent can read. The agent works with secret **refs** (`sec_...`) and env var **names** only. 2. **Use two keys.** The key available to agent tools has only the `manage` scope. Only Nomi's server-side code that injects env vars into dev containers, or shows a user their own database login, holds a key with `secrets:reveal`. Reveal calls are audit-logged. 3. **Creating orgs, apps, dev databases and secrets is an idempotent `PUT`** keyed by Nomi's own ids: retry freely on network errors and 5xx. Publish and duplicate are `POST`s; before retrying one, check the environment's last publish (`GET .../envs/{env}`). 4. **Verify every webhook signature** and treat delivery as at-least-once and unordered: dedupe on the event `id`, order by `created_at`. 5. **Prefer webhooks to polling.** If you must poll, poll the publish (`GET .../publishes/{id}`) no faster than every 3 seconds. 6. **Don't send `node_modules`, `.git`, build output or `.env` files** in the source zip. Shipyard builds from source. 7. **Accepted is not live.** `202` from publish or duplicate means the build started. Never show the user a link, or tell them the app is up, before `publish.succeeded` (or `status: "succeeded"` and `live: true` on the environment). Before then the URL returns a Shipyard 502/404 page. 8. **Never invent a URL.** Use `urls` from a succeeded publish/deployment. Failed and running deployments have none. 9. **A failed publish does not take the previous version down.** The old release keeps serving until a new one succeeds, so a failure is safe to retry. ## How Nomi maps to Shipyard | Nomi | Shipyard | Identifier you use | |---|---|---| | Organization (or personal workspace) | Organization | Your org id, as `{org}` (`external_id`) | | Project | App | A slug you choose, as `{app}` (`a-z0-9-`, max 63) | | Development | Dev database on a shared Postgres server | (per app) | | Published app | Environment: one Shipyard project per environment | `{env}`: `production`, `staging`, `preview-123`, ... | | Secrets the user enters | Secret store, app-wide or per environment | Env var name, e.g. `STRIPE_KEY` | Nomi decides each environment's address and sends it as a **full hostname** (`domain.hostname`, e.g. `todo.nomi.app` or `todo-staging.nomi.app`). Because Shipyard stores the whole domain, not just a label under its own base domain, a Nomi admin can move apps to a different domain at any time (`todo.nomi.app` → `todo.nomi.dev`) without any Shipyard config change. The hostname's DNS must point at this Shipyard's Traefik (a wildcard record per domain you use); TLS is issued automatically. If the manifest has no `domain.hostname`, Shipyard falls back to the legacy behavior: production gets `https://.`, other environments `https://-.`, with a random suffix when taken. Either way, show the URL Shipyard returns. To let a user pick their own name, see [Choosing a domain](#domains). ## Setup The Shipyard operator configures, in `/opt/shipyard/.env`: ```bash # Optional fallback: apps published without domain.hostname get . and -.; # needs a wildcard DNS record to Traefik. Not needed when Nomi always sends domain.hostname. SHIPYARD__INTEGRATION__APPS_BASE_DOMAIN=apps.example.dev # Postgres server for dev databases; the role needs only CREATEDB and CREATEROLE. SHIPYARD__INTEGRATION__DEV_DB_ADMIN_URL=postgres://devdb_admin:...@devdb.internal:5432/postgres # How dev containers reach that server, if different from the admin URL. SHIPYARD__INTEGRATION__DEV_DB_PUBLIC_HOST=devdb.example.dev SHIPYARD__INTEGRATION__DEV_DB_PUBLIC_PORT=5432 SHIPYARD__INTEGRATION__DEV_DB_SSLMODE=require # Tenants must not reach each other (see Guide → Tenant isolation). SHIPYARD__DOCKER__TENANT_ISOLATION=true ``` Then a Shipyard superadmin creates the integration and its keys once, in the dashboard API (session auth): ```bash # create the integration curl -X POST https:///api/admin/integrations \ -H "Authorization: Bearer " -H "Content-Type: application/json" \ -d '{"name":"nomi"}' # key for agent tools and normal operations curl -X POST https:///api/admin/integrations//keys \ -H "Authorization: Bearer " -H "Content-Type: application/json" \ -d '{"name":"nomi-agent","scopes":["manage"]}' # key for the server code that injects secrets curl -X POST https:///api/admin/integrations//keys \ -H "Authorization: Bearer " -H "Content-Type: application/json" \ -d '{"name":"nomi-injector","scopes":["manage","secrets:reveal"]}' ``` The full key is returned once. Store both in Nomi's server environment: ```bash SHIPYARD_URL=https:///openapi/v1/integration SHIPYARD_KEY=ship_int_... # manage SHIPYARD_REVEAL_KEY=ship_int_... # manage + secrets:reveal, server-only SHIPYARD_WEBHOOK_SECRET=whsec_... # from POST /webhooks, see below ``` Then register Nomi's webhook endpoint once (see [Webhooks](#webhooks)). ## 1. A user creates an organization When an organization (or a user's personal workspace) is created in Nomi, create it in Shipyard in the same request handler. Shipyard returns `201` the first time and `200` after, with the same org. ### PUT /openapi/v1/integration/orgs/{org} ```json // request { "name": "Ana's Workspace", "plan": "free", "labels": { "nomi.app/workspace": "personal" } } // response 201 { "data": { "id": "01a1...", "external_id": "org_2Nf8sK", "name": "Ana's Workspace", "slug": "ana-s-workspace-k3x9qa", "plan": "free", "created_at": "...", "integration": "nomi", "labels": { "shipyard.io/integration": "nomi", "nomi.app/workspace": "personal" }, "created": true } } ``` `plan` is optional (`free` on create). Send it again to change the plan. Renames: send the new `name`. `labels` is optional; see [Labels and versions](#labels). `integration` is the integration that created the org, and the Shipyard dashboard shows it as a badge on the org. ### GET /openapi/v1/integration/orgs/{org} Returns the org and its apps with their environments. ## 2. A user creates a project and starts building Create the app, with its dev database if the project needs Postgres: ### PUT /openapi/v1/integration/orgs/{org}/apps/{app} ```json // request { "name": "Todo", "dev_database": true } // response 201 { "data": { "key": "todo", "name": "Todo", "created": true, "dev_database": { "database": "dev_01a1...", "host": "devdb.example.dev", "port": 5432, "app_role": "dev_01a1..._app", "owner_role": "dev_01a1..._owner", "app_url_ref": "sec_61490a48...", "owner_url_ref": "sec_b76e4154...", "created": true } } } ``` You can also create or fetch the dev database on its own with `PUT` / `GET .../apps/{app}/dev-database`, and drop it with `DELETE`. **Wiring the dev container.** In the code that starts the dev container (not in agent tools): 1. Reveal `app_url_ref` with the reveal key: `POST /secrets/{app_url_ref}/reveal` returns `{ "value": "postgres://..." }`. 2. Set it as `DATABASE_URL` in the container's environment. 3. Tell the agent only that `DATABASE_URL` is set. Drizzle reads `process.env.DATABASE_URL`; `drizzle-kit push` or `migrate` work against it. **The user's own login.** `owner_url_ref` is the user's credential for their database client. Reveal it only in response to the user's own request in Nomi's UI, and show it only to them. The owner role can read and change everything the app creates. Each app's database is private: other apps' roles can't connect to it. The app role has a 20-connection limit; use a pool of 5 or fewer in dev. ## 3. A user publishes When the user clicks publish, send the source and a manifest. Shipyard builds it, provisions what the manifest asks for (database, volumes, domain, generated secrets) and deploys, database first. ### POST /openapi/v1/integration/orgs/{org}/apps/{app}/envs/{env}/publish `multipart/form-data` with two parts: | Part | Content | |---|---| | `manifest` | JSON, below | | `source` | Zip of the project (optional: omit it to re-apply a changed manifest to the last upload) | ```bash curl -X POST "$SHIPYARD_URL/orgs/org_2Nf8sK/apps/todo/envs/production/publish" \ -H "Authorization: Bearer $SHIPYARD_KEY" \ -F "manifest=` (production) or `-`. If the name is already used by another environment, a non-production environment gets `-` instead (`todo` → `todo-staging`) | | `domain.service` | Which web service gets the URL. Default: the first web service | | `labels` | Labels for the environment's project. See [Labels and versions](#labels) | | `services[].labels` | Labels for that service | Env values: | Value | Result | |---|---| | `"text"` | Plain value | | `{ "generate": "hex32" }` | Generated once per environment, then kept. Kinds: `password`, `hex32`, `base64_32`, `uuid` | | `{ "secret": "STRIPE_KEY" }` | A secret set with `PUT .../secrets/STRIPE_KEY`. Publish fails listing missing secrets | | `{ "database": "url" }` | From the environment's database: `url`, `host`, `port`, `user`, `password`, `name` | Unknown fields are rejected (a typo fails loudly instead of being ignored). ### Preparing the source - **Listen on `0.0.0.0` and the manifest `port`.** Read `PORT` if your framework does. - **SvelteKit:** use `@sveltejs/adapter-node`; set `ORIGIN` to the URL. Build output runs with `node build`. - **Next.js:** `output: "standalone"` in `next.config`; run `node server.js`. - **Static SPA (React, Vue):** a Dockerfile that builds and serves with a static server on the port. - **Migrations:** run them on start, before listening. With Drizzle, call `migrate(db, { migrationsFolder: "./drizzle" })` from `drizzle-orm/node-postgres/migrator` in the server entry, and commit the generated `drizzle/` folder. Don't use `drizzle-kit push` in production. - **Health:** respond `200` on `/` or a cheap route within a few seconds of starting. A Dockerfile that works for SvelteKit (adapter-node): ```dockerfile FROM node:22-alpine AS build WORKDIR /app COPY package*.json ./ RUN npm ci COPY . . RUN npm run build && npm prune --omit=dev FROM node:22-alpine WORKDIR /app COPY --from=build /app/build ./build COPY --from=build /app/node_modules ./node_modules COPY --from=build /app/package.json ./ COPY --from=build /app/drizzle ./drizzle ENV PORT=3000 EXPOSE 3000 CMD ["node", "build"] ``` ### Following a publish Webhooks tell you everything: `publish.started`, then for each service `deployment.started`, `deployment.step_started` / `deployment.step_succeeded` / `deployment.step_failed`, `deployment.succeeded` or `deployment.failed`, then `publish.succeeded` (with `urls`) or `publish.failed` (with `error`). `publish.succeeded` means every service is running, not just deployed. Show the user the URL from it. **Status vocabulary** (everywhere in this API): publish and deployment `status` is `running`, `succeeded` or `failed`. Shipyard's internal value is kept in `raw_status` for debugging; branch on `status`. Step `status` is `succeeded`, `failed`, `running`, `pending` or `skipped` (steps after a failed one are `skipped`). Each step has a `phase`: `validate`, `build`, `configure`, `deploy` or `finalize`. ### GET /openapi/v1/integration/orgs/{org}/apps/{app}/envs/{env}/publishes/{publish_id} ```json { "data": { "publish_id": "01a1...", "status": "succeeded", "urls": ["https://todo.apps.example.dev"], "error": null, "deployments": [ { "service": "db", "deployment_id": "01a1...", "status": "succeeded", "raw_status": "success" }, { "service": "web", "deployment_id": "01a1...", "status": "succeeded", "raw_status": "success" } ], "created_at": "...", "finished_at": "..." } } ``` On failure, `error` names the service and the cause; fetch that deployment ([below](#deployment-check)) for the step, phase and log tail. Other lookups: - `GET /orgs/{org}/publishes/{publish_id}`: same body, when you only have the id. - `GET /orgs/{org}/apps/{app}/publishes?limit=20`: recent publishes, newest first. ### Fast static publish (zero Docker overhead) When Nomi builds a purely static frontend (Vite React/Vue, SvelteKit static export, Next.js static export, Astro, HTML/JS), use the dedicated static publish endpoint. It runs with zero Docker overhead, serves instantly from Shipyard's serving layer, provides an immutable preview URL for every build, atomically updates the requested alias (e.g. `production` or an env name), and can optionally bind a custom domain with automated Let's Encrypt TLS and Cloudflare DNS management. #### POST /openapi/v1/integration/orgs/{org}/static-publish `multipart/form-data`: | Part | Description | Required | Default | |---|---|---|---| | `artifact` | Zip archive containing built static files (`index.html`, assets, etc.) | **Yes** | - | | `project_slug` | URL-safe slug for the project (e.g. `todo-client` or your `app` key) | **Yes** | - | | `name` | Human-readable name for the static project | No | Same as `project_slug` | | `alias` | Alias to point to this deployment (e.g. `production`, `staging`, `preview-1`) | No | `"production"` | | `domain` | Custom domain to bind to this project/alias (e.g. `app.example.com`) | No | - | | `spa_fallback` | Whether unknown routes fall back to `/index.html` (for client-side routing) | No | `true` | | `entry_file` | Primary entry file to serve and verify | No | `"index.html"` | | `metadata` | JSON object of arbitrary metadata (agent ID, prompt, commit) | No | `{"source": "nomi"}` | ```bash curl -X POST "$SHIPYARD_URL/orgs/org_2Nf8sK/static-publish" \ -H "Authorization: Bearer $SHIPYARD_KEY" \ -F "project_slug=todo-client" \ -F "alias=production" \ -F "domain=todo.example.com" \ -F "spa_fallback=true" \ -F "artifact=@dist.zip" ``` Response `200`: ```json { "success": true, "data": { "project_id": "0193309a-4f5b-7b00-8cb5-123456789abc", "project_slug": "todo-client", "deployment_id": "0193309a-5001-7c00-9abc-987654321def", "status": "READY", "preview_url": "/dpl/0193309a-5001-7c00-9abc-987654321def", "alias": "production", "alias_url": "/alias/0193309a-4f5b-7b00-8cb5-123456789abc/production", "domain": "todo.example.com", "domain_url": "https://todo.example.com", "archive_size_bytes": 1048576, "extracted_size_bytes": 3145728, "file_count": 28, "checksum_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" } } ``` - **Immediate Serving**: The status is `READY` immediately upon response. There is no container start or build delay. - **Custom Domain & Auto-SSL**: When `domain` is passed, Shipyard writes a Traefik router binding `Host(domain)` with automatic Let's Encrypt TLS. If the organization has a connected Cloudflare account, the DNS `A` record pointing to Shipyard is created automatically without manual steps. - **Auto-Unwrap**: If the zip wraps everything inside a single top-level folder like `dist/` or `build/`, Shipyard automatically unwraps it. - **Immutable Previews**: `preview_url` (`/dpl/{deployment_id}`) is immutable and forever pinned to this specific build. - **Zero-Downtime Aliases**: `alias_url` (`/alias/{project_id}/{alias}`) atomically switches to the new deployment with zero downtime. - **Cache Policy**: HTML and aliases are served with `Cache-Control: no-cache, no-store, must-revalidate`. Content-hashed assets (e.g. `main-a1b2c3d4.js`) are served with `Cache-Control: public, max-age=31536000, immutable`. ## 4. Environments Each environment is independent: its own Shipyard project with its own services, database, generated secrets, volumes and URL. Publish to any `{env}` name (`production`, `staging`, `dev`, `preview-123`, ...) to create it. You can publish the same manifest and source to each one, and publishing one environment never touches another. To start one from an existing environment: ### POST /openapi/v1/integration/orgs/{org}/apps/{app}/envs/{env}/duplicate ```json // request: copy production into staging { "to": "staging", "copy_secrets": true } ``` The copy uses the source environment's last manifest and source, gets an **empty** database, newly generated secrets and its own URL, and deploys. `copy_secrets` copies environment-specific user secrets; app-wide secrets are shared anyway. Response: same as publish (`202`). ### GET /openapi/v1/integration/orgs/{org}/apps/{app}/envs/{env} Services with status, running replica count, URLs, last deployment, `version` and `labels`, plus the environment's `labels` and the last publish. ```json { "data": { "environment": "production", "project_id": "01b2...", "labels": { "shipyard.io/integration": "nomi", "shipyard.io/app": "todo", "shipyard.io/environment": "production" }, "services": [{ "name": "web", "status": "running", "live": true, "urls": ["https://todo.apps.example.dev"], "version": "3f2a91c", "labels": { "shipyard.io/integration": "nomi", "shipyard.io/app": "todo", "shipyard.io/environment": "production", "shipyard.io/version": "3f2a91c" }, "...": "..." }], "last_publish": { "...": "..." } } } ``` ### DELETE /openapi/v1/integration/orgs/{org}/apps/{app}/envs/{env} Removes the environment: services, database and its data, volumes, domains and DNS records. ## Labels and versions Orgs, environments (projects) and services carry key/value labels. The Shipyard dashboard shows them as tags. The integration badge shows which platform manages an object, so support staff can tell a Nomi org from one created in the dashboard. **Labels Shipyard sets.** Keys under `shipyard.io/` are written by Shipyard and are read-only for you. If you send one, the request is refused with `400`. | Key | On | Value | |---|---|---| | `shipyard.io/integration` | org, project, service | The integration that manages it, e.g. `nomi` | | `shipyard.io/app` | project, service | The `{app}` key | | `shipyard.io/environment` | project, service | The `{env}` name | | `shipyard.io/version` | service | The tag of the image the service runs, set after each successful deploy | **Version.** Every git or publish build is tagged with the short commit sha of the source it built (the same `shipyard/:` tag shown in the deploy log). When the deploy succeeds, that tag becomes the service's `shipyard.io/version`, and `GET .../envs/{env}` returns it as `version`. A failed deploy leaves the previous version in place, matching what is actually serving. Services that run a registry image get the image's tag (`1.4.2`, `latest`). Static sites get the commit sha. **Your own labels.** Send `labels` on `PUT /orgs/{org}`, at the top level of the manifest (for the environment's project) and on each manifest service. Use them for anything Nomi wants to see next to the app in Shipyard, such as a template name, a Nomi build number or a release name. ```json { "labels": { "nomi.app/template": "nextjs", "nomi.app/release": "spring-launch", "old-label": null } } ``` - Labels are merged: keys you send are set, keys you leave out stay, and a `null` value removes that key. - Keys: 1–63 characters of `a-z 0-9 . / _ -`. Prefix them with `nomi.app/` so they never clash with another integration's labels. - Values: up to 256 characters. At most 32 labels per request. ## Choosing a domain Every environment gets its address on its first publish: `domain.hostname` from the manifest, or the legacy `.`. When a user wants a different name, use these endpoints. They take a **full hostname**, so the new name can be on any domain you point at Shipyard. Hostnames are shared across the whole platform, so one another app already uses is refused, and Shipyard suggests free ones nearby (same parent domain, different first label). Each endpoint still accepts the legacy `subdomain` (a label under the apps base domain) instead of `hostname`; send one or the other, never both. Typing flow in Nomi: call **check** as the user types (debounced), show the answer, and call **change** when they confirm. ### GET /openapi/v1/integration/orgs/{org}/domains/check?hostname=my-shop.nomi.app Optional `app` and `env`: with them, the environment's own current name counts as available (so re-confirming the current name isn't an error). ```json // free { "data": { "hostname": "my-shop.nomi.app", "subdomain": null, "available": true, "suggestions": [] } } // already used by someone else { "data": { "hostname": "todo.nomi.app", "subdomain": null, "available": false, "reason": "taken", "message": "'todo.nomi.app' is already in use", "suggestions": [ { "subdomain": "todo-app", "hostname": "todo-app.nomi.app" }, { "subdomain": "my-todo", "hostname": "my-todo.nomi.app" }, { "subdomain": "todo-hq", "hostname": "todo-hq.nomi.app" } ] } } // not a valid hostname { "data": { "hostname": null, "input": "ab_c.nomi.app", "available": false, "reason": "invalid", "message": "Use only letters a-z, digits 0-9, '-' and '.'", "suggestions": [ ... ] } } ``` The answer is `200` in every case; read `available`. Input is lowercased and trimmed (a trailing dot is dropped). `reason` is `taken` or `invalid`. A valid hostname has at least two labels, each 1–63 characters of `a-z 0-9 -` (not starting or ending with `-`), at most 253 characters in all, and is not an IP address or a wildcard. Directly under the apps base domain the stricter label rules still apply (3+ characters, reserved names such as `www`, `api`, `admin`, `mail`, `status` or anything starting `preview-` are refused), and the base domain itself can't be used. `subdomain` in the answer is the first label when the hostname sits directly under the apps base domain, else `null`. Suggestions are checked against the platform at the time of the call, but a name can still be taken a moment later, so handle `409` on change. ### PUT /openapi/v1/integration/orgs/{org}/apps/{app}/envs/{env}/domain ```json // request { "hostname": "my-shop.nomi.app" } // response 200 { "data": { "hostname": "my-shop.nomi.app", "url": "https://my-shop.nomi.app", "previous_hostname": "todo.nomi.app", "changed": true } } ``` - The new name is attached before the old one is removed, so the app stays reachable. The old name becomes free for others. - TLS and the DNS record for the new name are set up the same way as on publish. - Asking for the name the environment already has returns `200` with `"changed": false`. - If the name is taken or invalid, **nothing changes** and you get `409`, with the same `suggestions` under `data`: ```json { "data": { "available": false, "reason": "taken", "suggestions": [ { "subdomain": "todo-app", "hostname": "todo-app.nomi.app" } ] }, "error": { "code": "CONFLICT", "message": "'todo.nomi.app' is already in use" } } ``` - An environment that hasn't been published has no address yet: you get `409` until the first publish. - The manifest's `domain.hostname` wins on the next publish: if it differs from the name you set here, the publish moves the environment back to it. After a change, update the hostname Nomi stores for that environment so the next manifest carries it. A manifest without `domain.hostname` keeps the current name. - Moving every app to a new domain: point the new domain's DNS at Shipyard, then `PUT .../domain` (or republish) each environment with its new hostname. No Shipyard setting has to change. - Emits `domain.attached` for the new name and `domain.removed` for the old one. ### GET /openapi/v1/integration/orgs/{org}/apps/{app}/envs/{env}/domain Returns `{ "hostname", "subdomain", "url" }` for the environment's current address, or nulls when there is none yet. `subdomain` is only set when the hostname sits directly under the apps base domain. Only the one address Nomi manages per environment is handled here. Extra domains a user adds in the Shipyard dashboard are left alone. ## Moving an app to another organization When a project moves to another Nomi organization, move its Shipyard app too, so the two stay in step. Both organizations must belong to your integration (both created through it, or linked by a superadmin). ### POST /openapi/v1/integration/orgs/{org}/apps/{app}/transfer ```json // request { "to_org": "org_9Zk2", "app_key": "todo-2" } // app_key is optional // response 200 { "data": { "app": "todo", "from_org": "org_2Nf8sK", "to_org": "org_9Zk2", "environments": [ { "environment": "production", "project_id": "01a1...", "slug": "todo-production" } ], "redeploying": [ { "service": "web", "deployment_id": "01a1..." } ], "skipped": [] } } ``` What moves, in one step: the app, all its environments (production, staging, ...), their services and data, secrets (app-wide and per environment), the dev database, publish history and the image registry namespace. Nothing is copied, so **web addresses, volumes and databases stay as they are** and URLs keep working. What you should know: - **Containers restart once.** Each container service is redeployed from the image it already runs so it joins the new org's private network. Watch `redeploying[].deployment_id` like any deployment. Static sites don't restart. A service with no successful deployment is listed in `skipped`. - **Same app key.** If the destination already has an app with this key you get `409`; send `app_key` to rename it on the way. - **Slugs.** If a project slug is in use in the destination, the moved project gets a short suffix. The environment name does not change. - **Refused with `409`** while a publish or deployment of the app is running (try again when it finishes). **`403`** when the destination's plan can't hold the app's environments, or its billing doesn't allow deploys. `400` if `to_org` is the app's current org, `404` if either org is unknown to your key. - **Access.** Integration key only. Project-level access that was given to members of the old org is removed; the new org's members and roles apply. - **Events.** `app.transferred` is emitted in both orgs (data: `app`, `from_org`, `to_org`, `environments`). After this call `GET /orgs/{old}/apps/{app}` returns `404` and the app answers under the new org. - **Cloudflare.** DNS records created for the app's hostnames stay in place. If the new org uses a different Cloudflare connection, manage those hostnames there. ## 5. A user deletes a project or organization ### DELETE /openapi/v1/integration/orgs/{org}/apps/{app} Terminates the app everywhere: every environment (workloads, data, volumes, domains, DNS records, source), the dev database and its roles, and all secrets. Irreversible: ask the user to confirm in Nomi first. Answers `202 Accepted` right away and runs the termination in the background, because stopping every environment can take longer than a proxy waits for a response: ```json { "data": { "app": "todo", "status": "terminating", "environments": ["preview", "production"] } } ``` Shipyard emits `app.terminated` when the termination finishes. Until then, `GET /orgs/{org}/apps/{app}` still returns the app. Poll it until it returns `404`, or wait for the webhook. Sending `DELETE` again while the termination runs returns `202` again. If the termination fails partway, the app stays and the next `DELETE` resumes it. ### DELETE /openapi/v1/integration/orgs/{org} Terminates every app of the org, then deletes the org. Answers `202 Accepted` right away and runs the teardown in the background, because stopping every stack of a large org can take minutes: ```json { "data": { "deleted": "acme", "status": "deleting", "apps_terminated": ["todo", "blog"] } } ``` Shipyard emits `org.deleted` when the teardown finishes. Until then, `GET /orgs/{org}` still returns the org. Poll it until it returns `404`, or wait for the webhook. Sending `DELETE` again while the teardown runs returns `202` again. If the teardown fails partway, the org stays and the next `DELETE` resumes it. ## Secrets Users enter API keys and other secrets in Nomi's UI; Nomi's server stores them in Shipyard. The agent may name the secrets an app needs (`STRIPE_KEY`) and reference them in the manifest; it never sees or sets values. ### PUT /openapi/v1/integration/orgs/{org}/apps/{app}/secrets/{name} ```json // set a value (from the user, server-side only) { "value": "sk_live_...", "environment": "production" } // or let Shipyard generate one { "generate": "hex32" } // response: a ref, never the value { "data": { "ref": "sec_07db...", "name": "STRIPE_KEY", "environment": "production", "bound_env_vars_updated": 1 } } ``` Omit `environment` for an app-wide secret; an environment-specific one wins over it. Changing a secret updates every env var bound to it; services pick it up on their next publish or restart. ### GET /openapi/v1/integration/orgs/{org}/apps/{app}/secrets Names, refs, kinds, environments and where each is used (`production/web:STRIPE_KEY`). No values. ### DELETE /openapi/v1/integration/orgs/{org}/apps/{app}/secrets/{name} `?environment=` for an environment-specific one. Refused while an env var uses it. ### POST /openapi/v1/integration/secrets/{ref}/reveal Needs `secrets:reveal`. Returns `{ "ref", "name", "value" }`. Server-side only; see [Rules](#rules). ## Webhooks ### POST /openapi/v1/integration/webhooks ```json // request { "url": "https://nomi.example.com/hooks/shipyard", "events": ["*"] } // response: the secret is shown once { "data": { "id": "01a1...", "url": "...", "events": ["*"], "org": null, "secret": "whsec_..." } } ``` `events` takes exact types (`deployment.failed`), prefixes (`deployment.*`) or `*`. Add `"org": ""` to limit an endpoint to one org. Manage with `GET /webhooks`, `PATCH /webhooks/{id}` (`url`, `events`, `enabled`), `DELETE /webhooks/{id}`, `POST /webhooks/{id}/rotate-secret`, and `POST /webhooks/{id}/test` (sends a signed `ping` and returns the result). Each request is a `POST` with this JSON body: ```json { "id": "01a12df4-...", "type": "publish.succeeded", "created_at": "2026-10-12T02:32:49.966835Z", "org": { "id": "01a1...", "external_id": "org_2Nf8sK" }, "app": "todo", "environment": "production", "project_id": "01a1...", "service_id": null, "service": null, "data": { "publish_id": "01a1...", "urls": ["https://todo.apps.example.dev"], "error": null } } ``` Headers: `Shipyard-Event-Id`, `Shipyard-Event-Type`, `Shipyard-Delivery-Id`, and `Shipyard-Signature: t=,v1=`, where `v1 = HMAC-SHA256(secret, ".")`. Verify before parsing (Node): ```ts import crypto from "node:crypto"; export function verifyShipyard(rawBody: Buffer, header: string, secret: string, toleranceSec = 300): boolean { const parts = Object.fromEntries(header.split(",").map((p) => p.split("=", 2))); const t = Number(parts.t); if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false; const expected = crypto.createHmac("sha256", secret).update(`${t}.`).update(rawBody).digest("hex"); const given = Buffer.from(parts.v1 ?? "", "hex"); const want = Buffer.from(expected, "hex"); return given.length === want.length && crypto.timingSafeEqual(given, want); } ``` Reply `2xx` within 10 seconds; do slow work after replying. Anything else is retried after 10 s, 30 s, 2 min, 10 min, 30 min, 1 h, 2 h, 4 h, 8 h and 12 h, then marked failed. `GET /webhooks/{id}/deliveries?status=failed` lists failures; `POST /deliveries/{id}/redeliver` retries one. ### Event types | Type | When | `data` | |---|---|---| | `org.created`, `org.deleted` | Org provisioned / deleted | `name`, `plan` / `apps_terminated` | | `app.created`, `app.terminated` | App created / terminated | `app`, `environments` | | `project.created`, `project.duplicated`, `project.deleted` | Environment created / copied / removed | `app`, `environment`, `from_environment` | | `publish.started`, `publish.succeeded`, `publish.failed` | Publish lifecycle | `publish_id`, `urls`, `error`, `deployments` | | `deployment.started`, `deployment.succeeded`, `deployment.failed` | Per-service deploy | `deployment_id`, `urls`, `image`, `error` | | `deployment.step_started`, `deployment.step_succeeded`, `deployment.step_failed` | Build and deploy progress | `deployment_id`, `step`, `error` | | `service.created`, `service.deleted`, `service.env_changed` | Service changes | `slug`, `type` | | `service.crashed` | A container exited unexpectedly | `container_id`, `replica`, `exit_code` | | `service.resource_alert` | CPU or memory over threshold | `metric`, `value`, `threshold` | | `service.error_rate_alert` | 5xx error rate over threshold | `error_rate`, `threshold`, `requests`, `errors_5xx` | | `domain.attached`, `domain.removed` | Hostname added / removed | `hostname`, `tls` | | `volume.created`, `volume.attached`, `volume.deleted` | Volume changes | `volume`, `mount_path` | | `network.created`, `network.attached`, `network.detached`, `network.deleted` | Network changes | `network_id` | | `dev_database.created`, `dev_database.deleted` | Dev database | `database`, `app_url_ref`, `owner_url_ref` | | `secret.created`, `secret.updated`, `secret.deleted` | Secret store (never the value) | `ref`, `name`, `environment` | | `database.provisioned` | An environment's Postgres was created | `service`, `host` | `GET /event-types` lists them. The same envelopes are available as a feed: `GET /orgs/{org}/events?after=&type=deployment.*&app=todo&limit=100` returns `{ "events": [...], "next_after": "" }`, oldest first. Use it to catch up after downtime. Events are kept 30 days. ## Logs, deployments, usage and metrics Show users failures in their own words; give the agent the same logs so it can fix the app. Nomi's Monitor sheet (`MonitorPanel.svelte`) displays live usage, structured runtime logs, and 7-day traffic and container resource history. Secret values are masked as `****` in everything below. ### Checking a deployment The same body comes from any of: - `GET /orgs/{org}/apps/{app}/envs/{env}/deployments/{deployment_id}` - `GET /orgs/{org}/deployments/{deployment_id}` (no app/env needed) - `GET /deployments/{deployment_id}` (the id alone; the key's orgs are searched) Query: `logs=false` returns status and steps only; `after=` returns log lines after the previous call's `next_after`; `logs_limit` (default 2000, max 5000). ```json { "data": { "deployment_id": "01a1...", "service": "web", "status": "failed", "raw_status": "failed", "image": null, "urls": [], "failure": { "step": "build_image", "phase": "build", "message": "npm run build exited with code 1", "tail": ["src/routes/+page.svelte:12:5 'x' is not defined", "error during build"] }, "steps": [ { "name": "clone_repo", "phase": "build", "status": "succeeded", "started_at": "...", "finished_at": "..." }, { "name": "build_image", "phase": "build", "status": "failed", "error": "..." }, { "name": "deploy", "phase": "deploy", "status": "skipped" } ], "logs": [ { "level": "error", "message": "...", "at": "..." } ], "next_after": "2026-10-16T09:00:00Z" } } ``` `urls` is present only when `status` is `succeeded`. `failure` is `null` unless it failed. Poll with `after=next_after` to stream a running build without re-reading the log. - `GET .../envs/{env}/deployments?service=web&limit=20`: recent deployments, newest first. - `GET .../envs/{env}`: services with `status`, `running`/`replicas`, `urls`, `live` and the last deployment. `live` is `true` only when at least one replica is running and the service has a URL. ### Rollback and restart - `POST .../envs/{env}/services/{service}/rollback`, body optional `{ "deployment_id": "..." }`. Redeploys the image of an earlier successful deployment (default: the one before the current), without a rebuild, so it takes seconds. Database migrations are **not** undone. Calling it twice flips between the two newest releases. It returns `400` if there is nothing to return to. - `POST .../envs/{env}/restart`: restarts every service with its current env (use after changing a secret). Returns the services restarted. - `GET .../envs/{env}/services/{service}/env`: the service's env vars as `[{ name, secret, secret_ref }]`. Values are never returned. ### GET /openapi/v1/integration/orgs/{org}/apps/{app}/envs/{env}/services/{service}/logs Runtime output of all replicas. Query: `tail` (default 200, max 5000), `since` (`10m`, `1h` or RFC 3339). Returns `{ "service", "lines": [{ "at": "", "level": "info|warn|error", "message": "...", "service": "web" }] }`. Levels are extracted from structured JSON output where available and infer stream/severity otherwise. Secret values are masked. ### GET /openapi/v1/integration/orgs/{org}/apps/{app}/envs/{env}/usage Per running container: `cpu_percent`, `memory_usage_bytes`, `memory_limit_bytes`, network and block I/O, and configured limits. Containers on other nodes show `stats: null`. ### GET /openapi/v1/integration/orgs/{org}/apps/{app}/envs/{env}/metrics Bucketed traffic and container resource history. Query: `range` (`1h`, `24h`, `7d`; default `24h`), `step` (`1m`, `5m`, `1h`, `1d`; default matched to range). ```json { "data": { "range": "24h", "step": "1h", "buckets": [ { "at": "2026-10-26T09:00:00Z", "visitors": 34, "requests": 812, "errors_5xx": 2, "errors_4xx": 11, "p50_ms": 96, "p95_ms": 288, "containers": [ { "service": "web", "container_id": "…", "cpu_percent": 21.4, "memory_percent": 58.1, "memory_bytes": 312475648, "memory_limit_bytes": 536870912 } ] } ], "totals": { "visitors": 412, "requests": 9811, "errors_5xx": 38, "errors_4xx": 120, "p50_ms": 120, "p95_ms": 310 }, "previous_totals": { "visitors": 349, "requests": 8702, "errors_5xx": 41, "errors_4xx": 98, "p50_ms": 131, "p95_ms": 342 } }, "error": null } ``` Unmeasured metrics return `null`. Historical metrics are retained for at least 7 days. ## The fix loop for coding agents When a publish fails the agent should read the cause, fix the source and publish again. Do not retry blindly. ```ts async function publishUntilLive(ctx, maxAttempts = 3) { for (let attempt = 1; attempt <= maxAttempts; attempt++) { const pub = await shipyard.publish(ctx); // 202 const result = await waitForPublish(pub.publish_id); // webhook, or poll every 3 s if (result.status === "succeeded") return result.urls[0]; const failed = result.deployments.find((d) => d.status === "failed"); const dep = await shipyard.getDeployment(failed.deployment_id); const { phase, step, message, tail } = dep.failure; if (phase === "validate") await agent.fixManifest(message); // bad manifest, missing secret else if (phase === "build") await agent.fixCode(message, tail); // compile/type/install errors else if (phase === "configure") await agent.askUser(message); // secrets, limits, plan else /* deploy, finalize */ { // The image built but the app didn't come up: read runtime logs. const logs = await shipyard.serviceLogs(ctx, failed.service, { tail: 200 }); await agent.fixRuntime(message, logs.lines); } } throw new Error("giving up after 3 failed publishes"); } ``` | `failure.phase` | Usual cause | Agent action | |---|---|---| | `validate` | Manifest rejected, secret not set, zip missing `path` | Fix the manifest or set the secret (ask the user for values) | | `build` | `npm ci`/`build` failed, no Dockerfile detected, wrong Node version | Fix source; read `tail` | | `configure` | Plan limit, invalid resources, domain taken | Ask the user or lower the request | | `deploy` | Container exits at start, port mismatch, migration error, health check fails | Read service logs; listen on `0.0.0.0:$PORT`, check migrations | ## Framework recipes Shipyard detects SvelteKit, Next.js, Nuxt, Angular and TanStack Start when there is no Dockerfile, and runs Node 22. If detection fails or you need control, ship a Dockerfile. | Stack | Manifest service | Source requirements | |---|---|---| | SvelteKit (SSR) | `{ "name":"web", "kind":"web", "port":3000 }` | `@sveltejs/adapter-node`; set `ORIGIN` to the public URL | | SvelteKit (Static) | *Use `static-publish`* | `@sveltejs/adapter-static`; upload prebuilt `build.zip` directly | | Next.js (SSR) | `{ "name":"web", "kind":"web", "port":3000 }` | `output: "standalone"` | | Next.js (Static export) | *Use `static-publish`* | `output: "export"`; upload prebuilt `out.zip` directly | | Vite React/Vue (prebuilt static) | *Use `static-publish`* | `npm run build` outputs `dist/`; upload `dist.zip` for instant zero-container serving | | Vite React/Vue (build on deploy) | `{ "name":"site", "kind":"static" }` | `npm run build` outputs `dist/`; served by nginx with SPA fallback | | Node API + worker | two services, one `web`, one `worker` | Worker has no URL or port | `kind: "static"` builds the site in Docker and serves the output with nginx; the port is detected, so omit `port`. Alternatively, if your agent or CI already runs the build, call `POST /orgs/{org}/static-publish` to upload the prebuilt zip directly: it skips Docker entirely, deploys in milliseconds, and provides immutable previews. A static site has no server env at runtime: bake public config at build time. ## Verifying a Shipyard Before relying on a Shipyard instance, run the checker from the Shipyard repo. It uses only the Python standard library, creates a throwaway org, and removes it afterwards: ```bash SHIPYARD_URL=https:///openapi/v1/integration \ SHIPYARD_KEY=ship_int_... \ python3 scripts/check-nomi-integration.py --reveal-key ship_int_... # reveal key optional ``` It exercises auth, org and app creation, dev database (refs only), secrets, webhooks (with a test ping), publishing a static site and a container, rollback, restart, a deliberately failing build (checks the `failure` object and that the old version keeps serving), duplicate, terminate and org delete. It prints `ok`/`FAIL` per check and exits non-zero on failures. Publish checks need the instance to be able to build and pull images, so run it against a real deployment, not a bare dev stack. ## Troubleshooting | Symptom | Cause | Fix | |---|---|---| | `404 NOT_FOUND` on an org that exists | Org was created in the dashboard, not via this integration | Link it with the claim endpoint ([Credentials](#credentials)) | | `403` on `PUT /orgs/{org}` with an org key | Only integration keys create orgs | Use the integration key | | `403` on reveal | Key lacks `secrets:reveal` | Use the reveal key, server-side only | | Publish `403` | Org billing is canceled or past due | Fix the plan; `PUT /orgs/{org}` with `plan` if you own billing | | URL shows a Shipyard 502 after publish | You opened `url` before `publish.succeeded` | Wait for the webhook | | `publish.failed` at `validate` with "missing secret" | Manifest references `{ "secret": ... }` that was never set | `PUT .../secrets/{name}` first | | Container starts then exits | Not listening on `0.0.0.0:$PORT`, or migration error | Read service logs | | Webhook signature mismatch | Body was parsed before verifying | Verify against the raw bytes | ## Not available yet These came up in design and are **not** built. Don't call routes for them; use the workaround. | Feature | Status | Workaround | |---|---|---| | Promote a built image from staging to production without rebuilding | Not built | `duplicate` re-deploys from the same source (rebuilds); rollback only moves within one service | | Preview environments that expire automatically | Not built | Delete the environment yourself (`DELETE .../envs/{env}`) when the preview closes | | Sleep/wake of idle (free-plan) environments | Not built | Environments stay up; scale replicas to 0 via the dashboard if needed | | Server-sent-event log follow | Not built | Poll `logs?since=` or the deployment `after` cursor | | Members and login links (Nomi users opening the Shipyard dashboard) | Not built | Nomi is the interface; the superadmin can use the dashboard | | Org suspend | Not built | Delete the org, or cancel its plan | | Release command as a separate step | Not built | Run migrations on start | ## Errors and limits | Status | `error.code` | Typical cause | What to do | |---|---|---|---| | 400 | `BAD_REQUEST` | Invalid manifest, missing secret, bad name | Fix the request; the message says what | | 401 | `UNAUTHORIZED` | Missing, wrong or revoked key | Check `SHIPYARD_KEY` | | 403 | `FORBIDDEN` | Missing scope, plan limit | Use the reveal key / upgrade the plan | | 404 | `NOT_FOUND` | Unknown org, app, env, or one another integration owns | Create it first (`PUT`) | | 409 | `CONFLICT` | Environment exists (duplicate), secret in use | Pick another name / unbind first | | 5xx | `INTERNAL_ERROR`, `DATABASE_ERROR`, `DOCKER_ERROR` | Shipyard or Docker problem | Retry with backoff | Limits: source zip 200 MB (configurable), 20 services per environment, 1–20 replicas within the plan, projects per org per the plan (each environment is a project). ## Implementation checklist - [ ] Server env: `SHIPYARD_URL`, `SHIPYARD_KEY` (manage), `SHIPYARD_REVEAL_KEY` (manage + reveal, never exposed to agent tools), `SHIPYARD_WEBHOOK_SECRET`. - [ ] Webhook route: raw body, signature check, dedupe on event `id`, reply `2xx` fast, process async. - [ ] Org created in Nomi → `PUT /orgs/{org}`. Org renamed → same call. Org deleted → `DELETE /orgs/{org}` after user confirmation. - [ ] Project created → `PUT /orgs/{org}/apps/{app}` with `dev_database` when the stack uses Postgres. - [ ] Dev container start → reveal `app_url_ref` server-side → `DATABASE_URL` in the container env. Agent sees the name only. - [ ] "Database credentials" button → reveal `owner_url_ref` for that user only. - [ ] Secrets form → `PUT .../secrets/{name}` from the server; agent tools can list names and refs, never values. - [ ] Publish button → zip source (no `node_modules`, `.git`, `.env`, build output), build manifest, `POST .../publish`; show progress from `deployment.step_*`; on `publish.succeeded` show `urls[0]`; on `publish.failed` show `error` and fetch the deployment log for the agent. - [ ] "New environment" → `POST .../envs/{env}/duplicate`. - [ ] Every manifest carries `domain.hostname`, the full hostname Nomi stores for that environment (one per environment); DNS for each domain you use points at Shipyard. - [ ] "Change address" → `GET /orgs/{org}/domains/check?hostname=...` while typing, `PUT .../envs/{env}/domain` with `{ "hostname" }` on confirm, then store the new hostname; on `409` show `data.suggestions`. - [ ] Project moved to another org → `POST /orgs/{org}/apps/{app}/transfer` with `to_org`; store the returned app key if you passed `app_key`. - [ ] Project deleted → confirm → `DELETE /orgs/{org}/apps/{app}`. - [ ] Logs and Monitor panel → runtime logs (structured), live usage (`/usage`), and historical metrics (`/metrics`) for traffic and containers. - [ ] Monitor sheet alerts → handle `service.resource_alert` and `service.error_rate_alert` webhooks. - [ ] After publish: never show `url` until `publish.succeeded`; on failure read `failure` from the deployment and run the [fix loop](#fix-loop). - [ ] Run `scripts/check-nomi-integration.py` against the target Shipyard before go-live. - [ ] Catch-up job: on start, page `GET /orgs/{org}/events?after=` for orgs with activity. --- Source: https://shipyard.trian.space/docs/nomi # Design system Shipyard's dashboard is built on **Drydock**: graphite and concrete neutrals carry the interface, one signal colour marks what you can act on, and status colours are reserved for state. It is deliberately quiet. The interesting thing on screen should be your services, not the chrome around them. The source of truth is `frontend/src/routes/layout.css` (tokens) and `frontend/src/lib/components/ui/` (components). This page documents both. If you are building a screen, an extension or a generated UI, follow it. ## Principles 1. **One signal.** `--accent` (orange) means *this is the action* or *this is where you are*. Never use it for decoration, charts or illustration. 2. **Status is state.** Green, red, yellow and blue only describe the state of something — running, failed, pending, informational. They never decorate. 3. **Never colour alone.** Every status also has a shape or a word: a glyph, a label, a badge. Colour-blind users and grayscale screenshots must read the same. 4. **Flat by default.** Surfaces are separated by 1px rules, not shadows. Shadows exist only on things that float: menus, drawers, dialogs. 5. **Machines speak mono.** IBM Plex Mono is for what a machine produced or will read — image refs, ports, hashes, slugs, log lines. Prose is always Plex Sans. ## Colour All colours are CSS custom properties on `:root`, redefined under `[data-theme="dark"]`. Components reference tokens, never hex values. ### Surfaces | Token | Light | Dark | Use | | --- | --- | --- | --- | | `--bg-base` | `#ECECE8` | `#0E0F11` | App ground, canvas — "concrete" | | `--bg-surface` | `#FFFFFF` | `#17191C` | Cards, panels, inputs | | `--bg-elevated` | `#F7F7F5` | `#131518` | Side panels, table headers, field fills | | `--bg-hover` | `#E6E6E1` | `#22252A` | Hover state on rows and ghost controls | | `--border` | `#DCDCD6` | `#2A2D32` | Every 1px rule | | `--border-control` | `#8A9097` | `#6B7178` | Form field edges (3:1 against surfaces) | ### Navigation rail The rail is graphite in both themes so navigation reads as one fixed object. | Token | Light | Dark | Use | | --- | --- | --- | --- | | `--rail-bg` | `#141619` | `#0A0B0C` | Rail background | | `--rail-fg` | `#8B9097` | `#8B9097` | Idle icon and label | | `--rail-fg-active` | `#FFFFFF` | `#FFFFFF` | Current destination | | `--rail-hover-bg` | `#22252A` | `#1B1D21` | Icon tile on hover, rail hairlines | | `--rail-active-bg` | `#262A30` | `#22252A` | Icon tile of the current destination | ### Signal | Token | Light | Dark | Use | | --- | --- | --- | --- | | `--accent` | `#C2410C` | `#F26B1D` | Primary action, current location, focus ring | | `--accent-hover` | `#9A3412` | `#FB8B47` | Hover on primary | | `--accent-fg` | `#FFFFFF` | `#141619` | Text on an accent fill (graphite in dark: white on orange fails contrast) | | `--accent-muted` | 9% accent | 14% accent | Selected row, selected segment | ### Status Each status has a solid, a `-muted` fill for backgrounds and a `-text` for words on that fill. | Token | Light | Dark | Means | | --- | --- | --- | --- | | `--accent-green` | `#15803D` | `#4ADE80` | Running, healthy, succeeded | | `--accent-red` | `#B91C1C` | `#F87171` | Failed, error, destructive | | `--accent-yellow` | `#A16207` | `#FBBF24` | Pending, degraded, warning | | `--accent-blue` | `#1D4ED8` | `#60A5FA` | Informational, deploying | ### Text | Token | Light | Dark | Use | | --- | --- | --- | --- | | `--text-primary` | `#16181B` | `#ECEDEE` | Headings, values | | `--text-secondary` | `#41464D` | `#C3C6CA` | Body | | `--text-muted` | `#5F656C` | `#9097A0` | Labels, meta | | `--text-dim` | `#8A9097` | `#767C83` | Placeholders, disabled | ### Terminal Logs, build output and exec sessions sit on a dark surface in **both** themes, so output looks the same wherever it appears. | Token | Value | Use | | --- | --- | --- | | `--terminal-bg` | `#141619` | Console background | | `--terminal-border` | `#2A2D32` | Rules inside the console | | `--terminal-fg` | `#C3C6CA` | Output text | | `--terminal-dim` | `#8B9097` | Timestamps, debug lines, column heads | | `--terminal-red` | `#F87171` | Error level | | `--terminal-yellow` | `#FBBF24` | Warn level | | `--terminal-blue` | `#60A5FA` | Info level, links | | `--terminal-green` | `#4ADE80` | Live indicator, success | | `--terminal-orange` | `#F0B07E` | Origin column (service or source) | ## Typography | Role | Font | Size / weight | | --- | --- | --- | | Page title | IBM Plex Sans | 20px / 700, `-0.02em` | | Eyebrow | IBM Plex Sans | 11px / 700, `--accent` | | Section title | IBM Plex Sans | 13–14px / 600 | | Body | IBM Plex Sans | 13px / 400 | | Label | IBM Plex Sans | 11–12px / 500–600, `--text-muted` | | Machine output | IBM Plex Mono | 12px / 400 | | Metric | IBM Plex Mono | 18px / 600, tabular figures | Fonts are bundled with `@fontsource` rather than fetched from Google Fonts: Shipyard is self-hosted and often runs on networks without outside access. ## Shape, elevation, motion | Token | Value | Use | | --- | --- | --- | | `--radius-sm` | `4px` | Chips, segments inside a group | | `--radius-md` | `6px` | Buttons, inputs, icon tiles | | `--radius-lg` | `8px` | Cards, consoles, panels | | `--radius-xl` | `12px` | Floating layers only | | `--shadow-md` | — | Dropdowns, popovers | | `--shadow-lg` | — | Drawers, dialogs | | `--transition-fast` | `120ms` | Hover, colour changes | | `--transition-normal` | `200ms` | Indicators, drawers | | `--rail-width` | `64px` | Navigation rail | | `--slide-panel-width` | `480px` | Right-hand slide panels (`100vw` on phones) | All motion respects `prefers-reduced-motion`. Focus is always a 2px `--accent` outline with a 2px offset. ## Components Components live in `$lib/components/ui` and are imported from its index: ```svelte title="Usage" ``` ### Buttons `