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 itsPEPPER, thatrobots.txtforbids and that every page links to out of sight ([#bot-tripwire-link#]in the page shell). A request for it is answered with status418. 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 any418answer, 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 toignoreip, and run each filter withfail2ban-regexagainst 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 theext/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
secretsblock. - 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/datain every container. With live editing, mount the wholeext/instead, so there is one Git checkout forext:syncand not one per container. The volume must support file locks (flock): NFSv4 and Amazon EFS do. - Same
.env. Every container gets the samePEPPERand settings. - Load balancer. Point its check at
/healthand setTRUSTED_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.