# AGENTS.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 `AGENTS.md` in the repository root. The name is deliberately generic and
deliberately fixed — it is the convention agents look for, the way a person looks for
`README.md`. 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. *(Currently the
   whole of this document.)*

---

## 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 |
| **Image type** — `laravel` (PHP) or `web-base` (static) | `laravel` | the image the site was created from |

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) site the web root **is** that folder.
- **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 (`web-base` image)

```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 static, 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 you get `http://` links on an `https://` site.** The platform's
  Caddy terminates TLS and forwards plain HTTP to the container. A Laravel app that does not trust
  the proxy generates `http://` URLs and can mis-detect the scheme (broken asset links, redirect
  loops). Configure trusted proxies in `bootstrap/app.php`
  (`$middleware->trustProxies(at: '*', headers: Request::HEADER_X_FORWARDED_FOR | …HEADER_X_FORWARDED_PROTO | …)`)
  so it honours `X-Forwarded-Proto`. This is app config, not workflow — but every Laravel site here
  needs it.
- **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).

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

Queue workers, the scheduler, and one-shot startup tasks 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/`).

**The filenames are a FIXED LIST — the image reads these exact names and nothing else. There is no
wildcard, and a mistyped name is silently ignored (never warned about). On the `laravel` image the
three honoured files are:**

```
.nimbus/supervisor/queue-worker.conf
.nimbus/supervisor/scheduler.conf
.nimbus/supervisor/boot.conf
```

| 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` | `laravel` **and** `web-base` | runs once at container start, then exits (warm-ups, one-time setup) |

A **static (`web-base`) site** honours only **`boot.conf`** — it has no PHP, so `queue-worker.conf`
and `scheduler.conf` do not apply there.

**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`, `queue.conf`, or `queue-worker.conf.bak` is
**never loaded and never warned about** — if a program is not running, check the exact filename
first, character by character.

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 — 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 — the fastest way to tell a filename typo from a mistake inside the
file. Deeper detail and troubleshooting: chapter 05 (`background-jobs.html`).

## 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, linking the repo, enabling deployments, provisioning the database, and editing
`.env` are the operator's tasks in the panel (Database and Environment tabs), and are already done.
If deployment is not enabled yet, or you do not have the runner label, **stop and ask the human** —
you cannot do those parts from the repo.
