# deployment.md — working on a site hosted on Nimbus

**Audience: an AI coding agent.** You are working on a web application that is *hosted* on the
Nimbus platform. This file is the platform context you cannot get from the repository you are
looking at: how the environment is shaped, what is true at deploy time, and what you must not
break.

Keep it at `deployment.md` in the repository root. Everything an agent needs to know about this
platform belongs in this one file.

**Scope note.** This file is about working *inside a site's repository*. It is not how you
operate the box — creating sites, provisioning databases, unlocking addresses and the like are
panel operations, and an agent does those through the panel's **MCP server** or its **HTTP
API**. See <https://panel.nimbus-online.net/agents/>.

## Contents

1. [Deployment](#the-situation) — writing the workflow that ships this repo.
2. [Custom stacks](#custom-stacks--authoring-composeyaml) — the compose contract, if this repo is
   deployed as a stack rather than into a catalog site.

---

## First: which kind of site is this?

Nimbus runs two kinds of site, and they need completely different work from you.

| | **Catalog site** | **Custom stack** |
|---|---|---|
| What it is | One container from a platform image (`laravel` / `web-base` / `web-spa`) | A Docker Compose project built from this repository |
| Your job | Write `.gitea/workflows/deploy.yml` | Write a compliant `compose.yaml` |
| Read | everything below | [Custom stacks](#custom-stacks--authoring-composeyaml), and skip the rest |

Tell them apart from the repository itself: if it has a `compose.yaml` or `docker-compose.yml` at
the root and its own `Dockerfile`s, it is almost certainly a stack. **If it is ambiguous, ask the
human** — a stack has a *slug*, not a domain, and no runner label at all.

---

## The situation

A human operator has, in the control panel:

1. **Created the site** (a container that serves one domain).
2. **Linked the Git repository** and **enabled deployments** for it.

A deployment **runner is already registered and idle**, waiting for the first push. It runs
**inside the site's own container** — so it has the app's own tooling and can reload the app
itself. It never touches Docker or any other site.

**Your task:** write `.gitea/workflows/deploy.yml` in this repo so that a push deploys the site.
That is the entire job. Do not try to create the site, register runners, or configure the panel —
that is the human's side and is already done.

---

## The three facts you need (ask the human, or read the site's Deployments page)

| Fact | Example | Where it comes from |
|---|---|---|
| **Runner label** — goes in `runs-on:` | `no-example-com` | shown on the site's Deployments page; it is the container name |
| **Domain** — the site folder is `/var/www/virtual/<domain>` (Laravel deploys into its `application/`) | `example.com` | the site's domain |
| **Site type** — the panel's name for the image; `laravel` (PHP), `web-base` (static) and `web-spa` (single-page app) are the standard entries, and a box's catalog may hold others | `laravel` | shown on the site's General tab |

If you cannot get these, **ask the human for them before writing the workflow.** Guessing the
runner label will make the job never run.

---

## The deploy model (what is true about the environment)

- The job runs **in the site's container, as the `www-data` user**, over the live folder the
  site serves from: **`/var/www/virtual/<domain>`**.
  - For a **`laravel`** site the app deploys into that folder's **`application/`** subdirectory,
    and the web root is **`application/public/`**. Durable state lives OUTSIDE `application/`:
    **`<domain>/storage/`** (app data) and **`<domain>/.env`** (secrets), each symlinked back into
    `application/` by the deploy — so re-publishing `application/` never touches data or secrets.
  - For a **`web-base`** (static) or **`web-spa`** (single-page app) site the web root **is** that
    folder. The two deploy identically; they differ only in how the running site answers a URL
    that matches no file — `web-base` returns 404, `web-spa` returns `index.html` so the
    client-side router can take over.
- **Which of the two file-serving images a front-end wants**, stated so it can be applied without
  judgement: if the build produces a single `index.html` entry point and routes are resolved in
  the browser, the site should be on **`web-spa`**. If every URL corresponds to a file or folder
  on disk, **`web-base`**. If it serves PHP, **`laravel`**. The image is chosen when the site is
  created, so if it is wrong, say so — it is the human's to change in the panel, not yours.
- **Do not configure history fallback, URL rewrites, or cache headers.** There is nowhere to put
  them and the image already does both. Specifically: do not add a `_redirects`, `vercel.json`,
  `netlify.toml`, `.htaccess` or nginx snippet — none are read. Files under `assets/` are cached
  for a year and `index.html` is always revalidated, which is why a deploy takes effect at once;
  fingerprinted output under `assets/` (Vite's default) gets that for free, other locations still
  work but are not cached aggressively.
- **Two hooks bracket every deploy.** They are a CLI baked into the image:
  - `nimbus deploy:begin` — puts the site behind a maintenance page (HTTP 503) and stops its
    background workers, so visitors never see a half-published site or a half-run migration.
  - `nimbus deploy:end` — reloads PHP (resets opcache so new code goes live), restarts the
    workers, and lifts the maintenance page.
- **No Docker, no secrets to manage.** `actions/checkout` authenticates automatically. There is
  no Docker socket and no deploy key to set up.
- **Tools available in the container:** `git`, `rsync`, `node`/`npm`, `python3` in every image;
  **`php` and `composer`** additionally in the `laravel` image.

---

## The one rule you must not get wrong

The closing hook **must** carry `if: always()`:

```yaml
- run: nimbus deploy:end
  if: always()
```

Without it, a failed step (a red test, a broken build) stops the workflow before `deploy:end`
runs, and the site is **stuck behind the maintenance page** until a timeout expires. With it, the
page always lifts and visitors get the previous working site back. This is the single most
important line in the workflow.

---

## Template — Laravel (`laravel` image)

Save as `.gitea/workflows/deploy.yml`. Replace `RUNNER-LABEL` and `example.com`.

```yaml
name: Deploy
run-name: Deploy ${{ gitea.sha }}
on:
  push:
    branches: [main]          # your deploy branch

jobs:
  deploy:
    runs-on: RUNNER-LABEL     # e.g. no-example-com
    env:
      SITE_DIR: /var/www/virtual/example.com

    steps:
      - run: nimbus deploy:begin

      - uses: actions/checkout@v4

      - run: composer install --no-dev --optimize-autoloader --no-interaction

      # If the app builds front-end assets (Vite/Mix/npm), build them HERE so
      # public/build (or public/js,css) exists in what you publish. Skip this if
      # the app has no build step — but if it uses Vite and you omit it, the page
      # loads with CSS/JS 404s on an otherwise-green deploy.
      - run: npm ci && npm run build   # drop if the app has no npm build

      # Publish the whole repo into application/, replacing whatever was there
      # (--delete). storage/ and .env are kept OUTSIDE application/ and symlinked
      # back in, so this wipe never touches app data or secrets.
      - name: Publish into application/
        run: |
          mkdir -p "$SITE_DIR/application"
          rsync -a --delete --exclude '.git' \
            --exclude 'storage' --exclude '.env' \
            ./ "$SITE_DIR/application/"
          ln -sfn ../storage "$SITE_DIR/application/storage"   # -> durable <domain>/storage
          ln -sfn ../.env    "$SITE_DIR/application/.env"      # -> operator-placed <domain>/.env

      - name: Migrate & cache
        run: |
          cd "$SITE_DIR/application"
          php artisan storage:link
          php artisan migrate --force
          php artisan config:cache
          php artisan route:cache   # ONLY if the app has no closure routes — it errors otherwise; drop the line if so

      - run: nimbus deploy:end
        if: always()
```

## Template — static site or single-page app (`web-base` / `web-spa` image)

Identical for both images: publish the built output to the site folder. Nothing in the workflow
changes between them.

```yaml
name: Deploy
run-name: Deploy ${{ gitea.sha }}
on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: RUNNER-LABEL
    env:
      SITE_DIR: /var/www/virtual/example.com

    steps:
      - run: nimbus deploy:begin
      - uses: actions/checkout@v4

      # Build with the bundled Node. Drop these two lines for a plain-HTML site
      # and publish the repo as-is.
      - run: npm ci
      - run: npm run build           # outputs to ./dist

      - run: rsync -a --delete ./dist/ "$SITE_DIR/"

      - run: nimbus deploy:end
        if: always()
```

---

## How to adapt these correctly

- **`runs-on:`** must be the exact runner label. Nothing runs if it is wrong.
- **Publish target** is `/var/www/virtual/<domain>/application` for Laravel (its `public/` becomes
  the web root); for `web-base` and `web-spa`, publish the built output to
  `/var/www/virtual/<domain>` directly.
- **`storage/` and `.env` stay outside `application/`.** They live at `$SITE_DIR/storage` and
  `$SITE_DIR/.env` and are symlinked into `application/` by the deploy. This is what makes the
  `rsync --delete` over `application/` safe — it cannot reach data or secrets. `$SITE_DIR/storage`
  is created for you by the image (the standard Laravel tree); never publish over it.
- **Migrations** run against the site's database (`php artisan migrate --force`), from inside
  `application/`. The site's `.env` (via the symlink) provides the connection. The operator
  provisions that database in the panel (**Database** tab) and writes its `DB_*` credentials into
  `$SITE_DIR/.env` there (**Apply to .env**), so the connection is expected to already be present.
  If the app has no database, drop the migrate step.
- **`.env` / secrets**: they live in `$SITE_DIR/.env`, managed by the operator — either edited in
  the panel (**Environment** tab) or placed by hand. Either way it is **not** in the repo and not
  in the workflow: never commit secrets or echo them in a step, and do not have the workflow write
  `.env` (the operator owns it). **`APP_KEY` must be set** in that `.env` — a Laravel app will not
  boot without it; the operator ensures this (a fresh site's `.env` may not have one yet).
- **`.env` changes need a restart/redeploy to take effect.** Because the deploy runs
  `php artisan config:cache`, the app reads config from the compiled cache, not `.env`, at runtime.
  So editing `.env` in the panel's **Environment** tab does nothing until the next deploy or a site
  restart from the panel — expected, but a common source of "I changed it and nothing happened".
- **Behind the proxy — trust it, or the app sees one client and writes `http://` links.** The
  platform's Caddy terminates TLS and forwards plain HTTP to the container over the internal
  network. Until the app trusts the forwarded headers, `$request->ip()` returns the proxy's address
  for *every* visitor (so rate limiting shares one bucket, logs record one address, and any in-app
  IP check is meaningless) and generated URLs come out `http://` (redirect loops, broken assets,
  OAuth callback mismatches). Configure this **while scaffolding**, not after a bug report. It is
  app config, not workflow, and every Laravel site here needs it — in `bootstrap/app.php`:

  ```php
  $middleware->trustProxies(
      at: '*',
      headers: Request::HEADER_X_FORWARDED_FOR
          | Request::HEADER_X_FORWARDED_HOST
          | Request::HEADER_X_FORWARDED_PORT
          | Request::HEADER_X_FORWARDED_PROTO,
  );
  ```

  **`at: '*'` is correct on Nimbus and must not be carried elsewhere.** It is safe here because
  Caddy overwrites any inbound `X-Forwarded-For` (its own trusted-proxy list is empty) and nothing
  but Caddy can reach the container — there are no published host ports. Do not try to name the
  proxy's address instead: it is a container address, not stable, and not knowable from inside the
  app. On a host without those two properties the wildcard lets a visitor claim any address, so if
  this repo also deploys somewhere else, make the value configurable. The same applies to a
  **custom stack** in whatever framework it runs.
- **Background workers/scheduler ship in the repo** under `.nimbus/supervisor/` and land at
  `application/.nimbus/supervisor/`. If the app needs them, write those files too — see
  **Background programs** below.
- **Restarting: it depends on what changed.**
  - **Editing code** an existing worker (or the site) runs → **no restart.** `deploy:end` restarts
    php-fpm, which resets the opcache, so new code goes live.
  - **Adding or renaming a `.nimbus/supervisor/*.conf`** → **needs one site restart from the panel.**
    `deploy:end` only restarts *already-registered* programs; the `.nimbus/supervisor/` directory is
    read **only at container start**, so a brand-new program file is not picked up by the deploy
    alone. Deploy it, then restart the site once (subsequent code changes to that worker need no
    further restart).
  - **Restart, not Recreate.** The site page has both. `Restart` runs the same container again and
    is what everything above means. `Recreate` builds a new container from the image — needed only
    for a changed site type or a setting read at container creation, never for deployed code.

## Background programs — `.nimbus/supervisor/` (optional)

Queue workers, the scheduler, one-shot startup tasks, and any other long-running program are
**declared as files in the repo**, under **`.nimbus/supervisor/`** at the app root. They travel with
your code (`actions/checkout` lands them at `$SITE_DIR/application/.nimbus/supervisor/`).

**There are two places, and the distinction is the thing to get right:**

```
.nimbus/supervisor/
├── queue-worker.conf        a KNOWN NAME — see the table below
├── scheduler.conf           a KNOWN NAME
├── boot.conf                a KNOWN NAME
└── daemons.d/
    ├── importer.conf        ANY name, loaded
    └── websocket.conf       ANY name, loaded
```

**Directly in `.nimbus/supervisor/` the filenames are a FIXED LIST — the image reads these exact
names and nothing else.** There is no wildcard there, and a mistyped name is silently ignored
(never warned about).

| File | Runs on | Purpose |
|---|---|---|
| `queue-worker.conf` | `laravel` only | long-running queue workers, kept alive and restarted if they die |
| `scheduler.conf` | `laravel` only | the Laravel task scheduler |
| `boot.conf` | any site type | runs once at container start, then exits (warm-ups, one-time setup) |

**In `daemons.d/`, every `*.conf` is loaded**, one supervisor program each, names of your choosing,
no limit on how many, on **every** site type. Use it for anything the platform has no name for: an
importer, a websocket server, a second queue on a dedicated connection. The extension must be
exactly `.conf` — `importer.conf.bak` is not read.

**Program names must be unique across all files** — the name inside `[program:...]`, not the
filename. Two files declaring `[program:worker]` collide silently and one of them never runs.

A **static (`web-base`) site** has no PHP, so `queue-worker.conf` and `scheduler.conf` do not apply
there; `boot.conf` and `daemons.d/` do.

**Presence is the switch:** the file exists → the program runs; delete it → it stops. There is no
panel state to toggle. A file named `worker.conf` or `queue-worker.conf.bak` sitting loose in
`.nimbus/supervisor/` is **never loaded and never warned about** — if a program is not running,
check where the file is and how it is spelled before you look inside it.

Each file is a **complete** supervisor program definition — the image supplies no defaults. Use
**absolute** paths (your folder is mounted at the identical path inside the container), and log to
`/dev/stdout`/`/dev/stderr` so output lands beside nginx/PHP instead of a file nobody reads.

`queue-worker.conf`:

```ini
[program:queue-worker]
command=/usr/bin/php /var/www/virtual/example.com/application/artisan queue:work --sleep=3 --tries=3
directory=/var/www/virtual/example.com/application
user=www-data
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
```

`scheduler.conf` (Laravel's scheduler as a long-running program — it ticks every minute itself):

```ini
[program:scheduler]
command=/usr/bin/php /var/www/virtual/example.com/application/artisan schedule:work
directory=/var/www/virtual/example.com/application
user=www-data
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
```

`boot.conf` (one-shot — note `autorestart=false` + `exitcodes=0` + `startsecs=0`, so it runs once
and is not treated as a crash when it exits):

```ini
[program:warm-cache]
command=/usr/bin/php /var/www/virtual/example.com/application/artisan optimize
directory=/var/www/virtual/example.com/application
user=www-data
autostart=true
autorestart=false
startsecs=0
exitcodes=0
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
```

**Rules — break one and the file is skipped (with a log line), never fatal to the site:**

- It must parse as INI.
- Only `[program:…]`, `[group:…]`, `[eventlistener:…]` sections — you cannot redefine the daemon.
- Programs run as `www-data`: write `user=www-data` or omit `user=` entirely. **Any other user
  rejects the whole file** (it is not quietly rewritten).
- `command=` and `directory=` are absolute and point at `.../<domain>/application`.

**Read only at container start — so a NEW or renamed file needs one restart.** `deploy:end` restarts
programs that were *already* registered and resets the opcache, but it does **not** re-scan
`.nimbus/supervisor/`. So the first time you add `queue-worker.conf` (or rename one), the deploy goes
green yet the worker does not appear — the same is true of a new file in `daemons.d/` — you must
**restart the site once from the panel** to register
it. After that, editing the code the worker runs needs no further restart (the opcache reset in
`deploy:end` covers it); only adding/renaming/removing a `.conf` file needs another restart.

At start the container logs one line listing which programs loaded, plus a `SKIPPED` line (with the
reason) for each rejected file — a rejected daemon shows as `SKIPPED daemon-<name>.conf`. That line
is the fastest way to tell a filename typo from a mistake inside the file. A rejected file is skipped,
never fatal: the site keeps serving. Deeper detail and troubleshooting: chapter 05 (`background-jobs.html`).

## Custom stacks — authoring `compose.yaml`

*Skip this section entirely if the site is a catalog site.*

A stack is this repository, cloned onto the box by the panel and brought up with
`docker compose up -d --build`. **There is no workflow to write and no runner** — the human presses
**Deploy** in the panel. Your job is the compose file, and it must satisfy a contract that is
enforced on **every** deploy: a violation is refused before anything starts, with the previous
deployment left running.

Full reference: <https://panel.nimbus-online.net/help/stacks.html>

### Publishing a host port — the exact shape

You declare which ports the app needs; the **panel** decides what they are. There is no port
setting anywhere in the panel, so this is the only channel.

```yaml
ports:
  - "${NIMBUS_BIND:-127.0.0.1}:${NIMBUS_PORT_WEB:-8080}:80"
```

- `${NIMBUS_BIND}` — the bind address. The panel injects the Docker bridge address, which the
  platform's edge proxy can reach and the internet cannot. **A literal `127.0.0.1` is refused**:
  the proxy is itself a container, so a host-loopback port is unreachable from it and the domain
  would serve nothing.
- `${NIMBUS_PORT_<NAME>}` — the host port. Pick the `<NAME>` (`WEB`, `API`, …); the panel allocates
  one free port per distinct name and injects it. Both short and long syntax are accepted.
- **Write the `:-` defaults.** They never fire under the panel, and they are what keeps the repo
  runnable with a plain `docker compose up` on a developer's laptop. Prefer a high fallback.

| You write | Verdict |
|---|---|
| `"${NIMBUS_BIND:-127.0.0.1}:${NIMBUS_PORT_WEB:-8080}:80"` | accepted |
| `"8081:80"` | **refused** — fixed host port |
| `"${NIMBUS_BIND:-127.0.0.1}:8081:80"` | **refused** — fixed host port |
| `"127.0.0.1:${NIMBUS_PORT_WEB}:80"` | **refused** — literal loopback |
| `"${NIMBUS_PORT_API}:3000"` | **refused** — no bind address, publishes on every interface |
| `"0.0.0.0:${NIMBUS_PORT_API}:3000"` | **refused** — same |

Only publish services a domain will point at. Services that only other containers in the project
talk to need no `ports:` entry — they reach each other by service name on the project's network.

### Constructs that are refused

| Construct | Why |
|---|---|
| `container_name:` | the panel names containers; a fixed name collides across stacks |
| top-level `name:` | the panel owns the project name (`stk-<slug>`); yours would silently not apply |
| `external: true` volumes | reaches another project's data, and no backup can find it |
| `network_mode: host`, `pid/ipc/userns_mode: host` | leaves the project's namespaces |
| `network_mode: container:<other>` | joins a container outside the project |
| `privileged:`, `cap_add:`, `devices:`, `security_opt:` | root on the box, or a path to it |
| a bind mount sourced outside the repo's own directory | reads/writes the host |
| a volume `source` containing `${…}` | a variable can hide any host path |

Everything else is ordinary and fine: named volumes, `build:` contexts inside the repo,
`healthcheck`, `depends_on`, your own networks, `restart:` policies.

### Platform services

The shared MySQL and Redis are reachable **by name only** — the panel injects these at deploy time:

```
mysql.nimbus:3306
redis.nimbus:6379
```

Put **no IP address** in the repo, and do not use `host.docker.internal` — on a stack's own network
it resolves to a gateway with nothing listening. Note that when the panel provisions a database it
displays the host as `mysql`; in a stack's `.env` that value must be `mysql.nimbus`.

### Configuration and state

- The environment file is `/var/www/stacks/<slug>/.env`, **symlinked to `application/.env`** in the
  clone. Compose auto-loads it, and `env_file: .env` resolves to it. The human edits it in the panel.
  Do not commit secrets; reference them as `${VAR}` and list what the file must contain in the README.
- **Nothing durable may live inside the clone.** A deploy is `git pull` over it. Use named volumes
  for data — they are prefixed `stk-<slug>_` and survive deploys, rebuilds and restarts.
- Do not create `.nimbus/override.yml` — that path is panel-owned and lives outside the clone.

### Verify

1. `docker compose config` locally must succeed, and `docker compose up` must work standalone —
   that is what the `:-` defaults are for.
2. Ask the human to press **Deploy**. A refusal names the service and the construct; fix and push.
3. Confirm the app answers on the domain the human routed to the allocated port.

---

## Verify the deploy worked

After you commit and push the workflow:

1. The run should be **green**. If it is red, read the failing step's log — the most common causes
   are a wrong `runs-on:` label, a publish step that wrote nowhere, or a missing `if: always()`
   (which shows up as the site stuck on the maintenance page).
2. Load the site. During a deploy visitors briefly see a maintenance page; after it, the new code.
3. A second push with a visible change should serve the change — confirming the opcache reset.

## Out of scope for you

Creating the site or stack, linking the repo, enabling deployments, provisioning the database,
registering a stack's deploy key, routing domains, and editing `.env` are the operator's tasks in
the panel (the tabs on the site's own page — General, Deployments, Environment, Databases, Backup, Locking), and are already done. If
deployment is not enabled yet, or you do not have the runner label — or, for a stack, the human has
not yet deployed it once so there are no allocated ports — **stop and ask the human**. You cannot do
those parts from the repo.
