# Deployment

Deployment requirements, Docker, multiple containers and manual hosting.

Nimbly supports both container and manual VPS deployment. Containers package the
app and runtime together for repeatable image updates. Manual VPS deployment is
equally valid for self-managed Apache/PHP hosts where you prefer direct control
over the server. Choose the model that matches how the site will be operated.

Every site answers `/health` for a load balancer or container check: `200` with
`ok` when PHP runs and `ext/data` can be written, `503` otherwise. The answer is
never cached. An `ext/uri/health` route replaces it.

Behind a load balancer that ends TLS, name its address in `.env`:

```env
TRUSTED_PROXIES=10.0.0.0/8
```

Addresses and ranges, separated by commas. A request that comes straight from
one of them gets its scheme, port and visitor address from `X-Forwarded-Proto`,
`X-Forwarded-Port` and `X-Forwarded-For`, so the session cookie is `secure`,
absolute links start with `https://` and the login pause and the statistics
count the visitor rather than the balancer. From any other address, and
without the setting, these headers are ignored.

The web server's own access log, and what reads it (the bot rules below), need
the visitor's address as well. The container images arrange that at start from
the same setting. On a server of your own, enable `mod_remoteip` and put the
lines this prints in the site's `<VirtualHost>` (not in `.htaccess`), again
after a change of the setting:

```bash
sudo a2enmod remoteip
./nimbly host:proxy-conf
```

A ban by address stops nothing behind a load balancer: the connection comes
from the balancer. Have the balancer or its firewall refuse the addresses the
rules find.

## Bots

Bots are welcome when they follow `robots.txt`, ask only for addresses the
site published, and read at most a page a second. Nimbly answers accurately and
leaves the banning to the server:

- **Tripwire.** Every site has one address, `/nb-` plus twelve characters
  derived from its `PEPPER`, that `robots.txt` forbids and that every page
  links to out of sight (`[#bot-tripwire-link#]` in the page shell). A request
  for it is answered with status `418`. The address is fixed per site and
  differs between sites; a page shell of your own needs the shortcode too.
- **Server rules.** `core/cli/setup/fail2ban/` holds three fail2ban filters and
  one jail file for a server with Apache access logs: a ban on any `418`
  answer, on more than 60 page requests in a minute, and on 20 not-found
  answers in ten minutes. Images, styles, scripts, API calls and admin screens
  are not page requests, and a missing image, style, script or document does
  not count as not found: that is the site's own broken link. Copy the filters to `/etc/fail2ban/filter.d/` and the
  jail file to `/etc/fail2ban/jail.d/`, add your own servers to `ignoreip`, and
  run each filter with `fail2ban-regex` against your logs before switching it
  on.

## Docker deployment

From the project checkout, generate the app image files in `ext/`:

```bash
./nimbly docker:init
```

Commit the generated `ext/Dockerfile`, `ext/.dockerignore`, and
`ext/.github/workflows/docker-publish.yml` in the `ext/` repository. On push,
GitHub Actions builds the app image from the current Nimbly `master` image and
publishes it to GHCR. Core continuously publishes
`ghcr.io/nimbly-web-systems/nimbly-core:master`; generated application builds
use that image and clone the matching `master` source for their asset build.

For local deployment testing without waiting for GitHub Actions to publish a
new core image, build a local core image and pass it as the app build base:

```bash
docker build -t nimbly-core-local:master -f docker/prod/Dockerfile .
docker build -t nimbly-app-local:test \
  --build-arg NIMBLY_BASE_IMAGE=nimbly-core-local:master \
  -f ext/Dockerfile ext
```

Choose the production data source-of-truth before deploying:

- **Immutable dev-to-live:** edit content in development, commit it to `ext/`,
  and deploy a new image. Production should not be used as an editor. No Git
  write token is needed.
- **Live editing:** editors change content, uploads, or resource records on the
  production site. In this mode production writes to `ext/data/`, and those
  changes must be pushed back to the `ext/` repository before the next deploy
  replaces the container.

For live editing, the production image must include `ext/.git` and the container
must receive `GIT_TOKEN`. The prod and stage schedules include `ext:sync`, which
commits and pushes data changes back to the `ext/` repository. For GitHub, use a
fine-grained token scoped to the `ext/` repository with **Contents: Read and
write**. The container configures Git credentials from this token at startup; do
not commit the token or bake it into the image.

For local Docker testing or a self-managed VPS, an untracked env file is fine:

```bash
docker run --rm --env-file .env.prod ghcr.io/your-org/your-nimbly-app:main
```

Keep `.env`, `.env.prod`, `.env.stage`, and similar files out of Git. Commit
only example files such as `.env.example` or `.env.prod.example` with dummy
values. On managed container platforms, configure these as runtime environment
variables or secrets instead:

- **AWS ECS/Fargate:** store secrets in AWS Secrets Manager or SSM Parameter
  Store and reference them from the task definition `secrets` block.
- **AWS App Runner:** configure runtime environment variables and secrets on the
  service.
- **Other container hosts:** use the platform's secret/env mechanism, not the
  Docker image build.

Minimal production Compose example:

```yaml
services:
  web:
    image: ghcr.io/your-org/your-nimbly-app:main
    restart: unless-stopped
    env_file:
      - .env.prod
    ports:
      - "80:80"
    environment:
      APP_ENV: prod
      SITE_URL: https://example.com
      SITE_NAME: Example
      MAIL_SERVICE: resend
      MAIL_FROM: no-reply@example.com
      MAIL_FROM_NAME: Example
    volumes:
      - nimbly-data:/var/www/nimbly/ext/data

volumes:
  nimbly-data:
```

If production content is edited in place, do not add `.git` to
`ext/.dockerignore`. Without `ext/.git` or `GIT_TOKEN`, the container still
starts, but `ext:sync` cannot push live data changes and those changes may be
lost on the next image deployment.

Container updates are image updates: push `ext/`, let the workflow publish a
new image, pull the image on the host, and restart the container. The container
runs `system:setup` at startup and runs the scheduler every minute. In live
editing mode, make sure `ext:sync` has pushed current production data before
replacing the container.

## Several containers

A site can run in several identical containers behind a load balancer. There is
no setting for it: everything a site remembers is a file under `ext/`
(records, uploads, sessions, jobs, scheduler state, request statistics,
caches), so the containers only have to share that folder.

- **One volume.** Mount the same volume at `/var/www/nimbly/ext/data` in every
  container. With live editing, mount the whole `ext/` instead, so there is one
  Git checkout for `ext:sync` and not one per container. The volume must
  support file locks (`flock`): NFSv4 and Amazon EFS do.
- **Same `.env`.** Every container gets the same `PEPPER` and settings.
- **Load balancer.** Point its check at `/health` and set `TRUSTED_PROXIES`
  (above) to its address. No sticky sessions are needed: a login on one
  container is valid on the others.
- **Scheduler.** Every container runs it each minute; a lock on the shared
  volume lets one of them do the work, so a task runs once.
- **Writes.** Changes to the records of a resource happen one at a time across
  all containers, so two editors saving the same record lose nothing.

```yaml
services:
  web:
    image: ghcr.io/your-org/your-nimbly-app:main
    restart: unless-stopped
    env_file:
      - .env.prod
    deploy:
      replicas: 2
    volumes:
      - nimbly-data:/var/www/nimbly/ext/data
      - nimbly-thumbs:/var/www/nimbly/ext/static/_thumb_

volumes:
  nimbly-data:
    driver_opts:
      type: nfs
      o: addr=10.0.0.10,nfsvers=4.1,rw
      device: ":/nimbly/example/data"
  nimbly-thumbs:
    driver_opts:
      type: nfs
      o: addr=10.0.0.10,nfsvers=4.1,rw
      device: ":/nimbly/example/thumbs"
```

Thumbnails are cached in `ext/static/_thumb_`. Mount a second shared volume
there as well: otherwise each container makes its own, and the thumbnails of a
deleted image stay behind on the containers that did not handle the delete.

Every container runs `system:setup` when it starts and then makes `www-data`
the owner of the site's files, the shared volume included. So the volume must
let root change owners (on NFS: `no_root_squash`, or an export already owned
by `www-data`), and a volume with very many files makes a start slow. Two
containers may start on an empty volume at the same moment.

`core/tests/multi-node.sh` tries this on a workstation: it starts a throwaway
site in two containers on one folder (`docker/dev/docker-compose.nodes.yml`)
and checks the login, concurrent writes to one record, the scheduler and the
request statistics. It has run on a local disk only, not on a network volume
and not behind a real load balancer.

## Manual VPS deployment

Manual VPS deployment is supported for self-managed installations. The release
baseline is the same path used in CI:

```bash
./nimbly deps
APP_ENV=prod SITE_NAME="My Site" ADMIN_EMAIL=admin@example.com ADMIN_PASSWORD="change-me" PEPPER="$PEPPER" ./nimbly system:setup
./nimbly build
./nimbly help
find core -name '*.php' -print0 | xargs -0 -n1 php -l
```

When host PHP is not available, use the Docker-backed CLI and PHP lint:

```bash
APP_ENV=prod SITE_NAME="My Site" ADMIN_EMAIL=admin@example.com ADMIN_PASSWORD="change-me" PEPPER="$PEPPER" ./nimbly --docker system:setup
docker compose -f docker/dev/docker-compose.yml run --rm --build nimbly sh -lc "find core -name '*.php' -print0 | xargs -0 -n1 php -l"
```

Keep `.env` on the target host and ensure it contains the production `APP_ENV`, stable `PEPPER`, canonical `SITE_URL`, and mail settings. Re-running `system:setup` is idempotent and is safe when a deployment needs to create missing directories, routes, roles, `.jobs`, or `.htaccess`.

## Scheduler and jobs

For manual VPS deployments, install the scheduler orchestrator once per server.
Register every deployed project; scheduling is required even without application-specific tasks:

```bash
sudo php /var/www/site/core/cli/nimbly.php scheduler:install
sudo php /var/www/site/core/cli/nimbly.php scheduler:project:add site /var/www/site
sudo php /var/www/site/core/cli/nimbly.php scheduler:cron:install --user=www-data
```

This creates `/etc/cron.d/nimbly-scheduler`, which runs
`/usr/local/bin/nimbly-scheduler-orchestrator` every minute as `www-data`, and
`/etc/logrotate.d/nimbly-scheduler`, which rotates the scheduler log weekly. The
orchestrator then dispatches registered project schedulers independently. In Docker
containers the scheduler runs automatically, so no host cron setup is required.

Run `schedule:init` once to create project schedule files in `ext/cli/`:

```bash
./nimbly schedule:init
```

This generates three files:

| File | Used when |
|---|---|
| `ext/cli/schedule.inc` | `APP_ENV=dev` or no env-specific file found |
| `ext/cli/schedule.prod.inc` | `APP_ENV=prod` |
| `ext/cli/schedule.stage.inc` | `APP_ENV=stage` |

The prod and stage schedules include `ext:sync`, which commits and pushes data changes in `ext/` back to the repository every hour. The dev schedule does not. Customize each file independently for environment-specific tasks.

When migrating an existing VPS, first install the orchestrator, register the
projects, run `scheduler:run --dry-run`, run the wrapper once
manually as `www-data`, and confirm `/var/log/nimbly-scheduler.log`. Back up the
old `www-data` crontab under `/etc/nimbly/backups/`, then remove only migrated
per-project `schedule:run` lines. Do not remove unrelated cron jobs.

Admin `git pull` remains available under Settings as a simple self-managed update option. For release-managed sites, prefer a repeatable deploy path: pull the intended git revision, run setup/build/lint, and make sure the scheduler is active.

---
