Developing Nibble
Deployment
Kamal onto one server per environment, and what a container does before it serves.
Nibble deploys with Kamal: a Docker image onto one server per environment. There is
no Kubernetes, no managed database and no separate front end to deploy — SQLite and the uploads live on the
server’s storage volume, and the image runs everything.
Inside the container: Puma behind Thruster, the Node server-rendering process started by Puma’s inertia_ssr
plugin, and Solid Queue running inside Puma for scheduled publishing and background work.
The commands
bin/kamal setup # first deploy to a new server
bin/kamal setup -d staging # the same, for staging
bin/kamal deploy
bin/kamal deploy -d staging
bin/kamal console # also: shell, logs, dbc
Before the first deploy
Deploy files. config/deploy.yml and config/deploy.staging.yml are written by the installer. If you
skipped that question, add them now:
bin/rails nibble:install --only=deploy
Credentials, per environment:
bin/rails credentials:edit --environment production
Each environment needs:
| Key | For |
|---|---|
kamal_registry_password |
pushing and pulling the image |
smtp.username, smtp.password |
outgoing mail |
aws.access_key_id, aws.secret_access_key |
uploads, and the nightly backup |
nibble.secrets.<name> |
any outbound connection that uses a secret |
basic_auth.username, basic_auth.password |
staging only |
.kamal/secrets reads RAILS_MASTER_KEY from config/credentials/<environment>.key, and the registry password
from those credentials. Those key files are gitignored — keep your own backup, because without them an
environment cannot be deployed or booted.
An S3 bucket per environment. The buckets do not need to be public: images are served through the application, which is what lets it crop and resize them. The control panel uploads straight from the browser, so each bucket needs a CORS rule for its own site:
[
{
"AllowedOrigins": ["https://your-site.example"],
"AllowedMethods": ["PUT"],
"AllowedHeaders": ["Content-Type", "Content-MD5", "Content-Disposition"],
"MaxAgeSeconds": 3600
}
]
Seeding a new database, once
A new environment starts with an empty database, and nothing seeds it for you. The theme’s content package — its pages, posts, taxonomy terms, globals and navigation — is imported by hand, once, after the first deploy:
bin/kamal app exec "bin/rails nibble:content:import"
Until you run it, a site answers 404 for every page it has not been given: the schema is there, the sitemaps list each collection, and every one of them is empty.
This is a one-time step, not part of a deploy. It is left out of the boot on purpose: the package is a starting point, so re-importing it on every deploy would fight whatever has since been written in the control panel. Collections written as Markdown are the exception and are handled below, because a folder of files stays the source of truth for them.
What happens on every deploy
Before the new container accepts a single request, it runs bin/rails nibble:upgrade, which:
- Refuses a theme built for a different Nibble version.
- Runs database migrations and content migrations.
- Runs
bin/rails nibble:check, which refuses a schema change that would strand stored content. - Records the schema it is serving.
If any of that refuses, the new container never becomes healthy, Kamal keeps the old one serving, and the
container log says exactly why. A bad deploy is a non-event rather than an outage. --allow-data-loss is the
deliberate override when you really do mean to drop content.
Database migrations run before that check, as in any Rails deploy, so write them to work with the release still serving.
It then runs bin/rails nibble:content:markdown, so every collection written as Markdown matches the folder in
the image: merging a page publishes it, and deleting one trashes it, with no step to remember. The sync is
idempotent, so a boot that changes nothing writes nothing, and a site with no Markdown collections skips it.
Staging
Staging differs in three ways, all on purpose:
- It is behind HTTP Basic Auth, everything except
/up— the health check has to stay reachable. If neither credentials nor environment variables are set, requests fail with a clear error rather than silently letting the world in. - It asks not to be indexed:
X-Robots-Tag: noindex, and arobots.txtthat disallows everything. - It has its own bucket and its own credentials, so a mistake on staging cannot touch production’s files.
Health checks and CDNs
The health check is Rails’ own /up.
A proxying CDN in front of the application works with no cache rule to write. Asset URLs
(/assets/<uuid>/…, versioned by content and crop) and the Vite build already return long-lived
Cache-Control headers.
HTML stays uncached at the CDN on purpose (Cache-Control: private). Caching a full page would risk sharing
one visitor’s session and CSRF token with another. Nibble’s own page cache is inside the application, where it
knows who is asking.
Backups
A nightly job takes a compacted copy of the database, keeps a week of them on the server and uploads them to
DB_SNAPSHOT_BUCKET if one is set. The cache, queue and cable databases are not backed up — they are ephemeral.
The whole thing, including how to restore, is in Backups.