Skip to content

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:

  1. Refuses a theme built for a different Nibble version.
  2. Runs database migrations and content migrations.
  3. Runs bin/rails nibble:check, which refuses a schema change that would strand stored content.
  4. 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 a robots.txt that 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.