Operate

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:

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:

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/:

./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:

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:

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:

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.
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:

./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:

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:

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/:

./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.


View this page as Markdown