Deploy Basecamp Fizzy with solo
Fizzy is Basecamp’s open-source Rails kanban app. It is a useful
example because it is not a toy: it ships a production Dockerfile, uses SQLite-backed Rails subsystems,
has recurring Solid Queue jobs, needs persistent storage, has secrets, sends mail, and exposes the standard
Rails /up health check.
This guide shows the same deployment concerns Fizzy’s config/deploy.yml describes for Kamal, but with
devopsellence solo: local config and secrets, SSH to your VM, direct image transfer, desired-state
publication, and a deterministic node agent reconcile loop.
It then expands the single-service deployment to run Solid Queue as a separate worker service instead of
inside Puma.
What changes from Kamal
Section titled “What changes from Kamal”Fizzy’s Kamal config has these deployment concerns:
- one
webcontainer built from the app’sDockerfile - port
80inside the container, served by Thruster /rails/storagepersisted for SQLite, Solid Queue, Solid Cache, Solid Cable, and Active Storage- automatic TLS for
fizzy.example.com - clear environment values such as
BASE_URL,MAILER_FROM_ADDRESS,SMTP_ADDRESS, andSOLID_QUEUE_IN_PUMA=true - secrets such as
SECRET_KEY_BASE, VAPID keys, and SMTP credentials
In devopsellence those concerns live in devopsellence.yml plus local solo secrets. Node inventory stays
outside the app config.
Prerequisites
Section titled “Prerequisites”- a VM reachable over SSH with Docker installed, or a provider-backed solo node created by devopsellence
- a DNS name such as
fizzy.example.compointing at the VM - the devopsellence CLI installed locally
- a local clone of Fizzy
git clone https://github.com/basecamp/fizzy.gitcd fizzyIf this is the first devopsellence deployment from the repo, initialize solo mode:
devopsellence init --mode soloStart with the Kamal-equivalent single-service shape
Section titled “Start with the Kamal-equivalent single-service shape”For the closest Kamal translation, keep Solid Queue inside Puma. Replace fizzy.example.com,
ops@example.com, and mail settings with your values.
schema_version: 1organization: soloproject: fizzydefault_environment: production
build: context: . dockerfile: Dockerfile platforms: - linux/amd64
services: web: ports: - name: http port: 80 healthcheck: path: /up port: 80 volumes: - source: fizzy_storage target: /rails/storage env: RAILS_ENV: production BASE_URL: https://fizzy.example.com MAILER_FROM_ADDRESS: support@example.com SMTP_ADDRESS: mail.example.com MULTI_TENANT: "false" SOLID_QUEUE_IN_PUMA: "true" secret_refs: - name: SECRET_KEY_BASE secret: SECRET_KEY_BASE - name: VAPID_PUBLIC_KEY secret: VAPID_PUBLIC_KEY - name: VAPID_PRIVATE_KEY secret: VAPID_PRIVATE_KEY - name: SMTP_USERNAME secret: SMTP_USERNAME - name: SMTP_PASSWORD secret: SMTP_PASSWORD
tasks: release: service: web command: - ./bin/rails - db:prepare
ingress: hosts: - fizzy.example.com rules: - match: host: fizzy.example.com path_prefix: / target: service: web port: http tls: mode: auto email: ops@example.com redirect_http: trueWhy this shape works:
- Fizzy’s Dockerfile exposes port
80, so the service port and health check target80. - Fizzy’s container entrypoint runs
db:preparebefore the Rails server. The release task runs it before rollout as well, so the new release is prepared before traffic moves. - The named volume maps to
/rails/storage, matching Fizzy’s Kamal volume and Rails SQLite storage paths. SOLID_QUEUE_IN_PUMA=truekeeps the OSS single-node deployment to one service container.
Split Solid Queue into a worker service
Section titled “Split Solid Queue into a worker service”Fizzy also ships bin/jobs, which runs SolidQueue::Cli. To run jobs separately, disable the Puma Solid
Queue plugin in web, then add a worker service that uses the same image, storage volume, env, and
secrets.
schema_version: 1organization: soloproject: fizzydefault_environment: production
build: context: . dockerfile: Dockerfile platforms: - linux/amd64
services: web: ports: - name: http port: 80 healthcheck: path: /up port: 80 volumes: - source: fizzy_storage target: /rails/storage env: &app_env RAILS_ENV: production BASE_URL: https://fizzy.example.com MAILER_FROM_ADDRESS: support@example.com SMTP_ADDRESS: mail.example.com MULTI_TENANT: "false" SOLID_QUEUE_IN_PUMA: "false" secret_refs: &app_secrets - name: SECRET_KEY_BASE secret: SECRET_KEY_BASE - name: VAPID_PUBLIC_KEY secret: VAPID_PUBLIC_KEY - name: VAPID_PRIVATE_KEY secret: VAPID_PRIVATE_KEY - name: SMTP_USERNAME secret: SMTP_USERNAME - name: SMTP_PASSWORD secret: SMTP_PASSWORD
worker: command: - ./bin/jobs volumes: - source: fizzy_storage target: /rails/storage env: *app_env secret_refs: *app_secrets
tasks: release: service: web command: - ./bin/rails - db:prepare
ingress: hosts: - fizzy.example.com rules: - match: host: fizzy.example.com path_prefix: / target: service: web port: http tls: mode: auto email: ops@example.com redirect_http: trueThe worker has no ports, healthcheck, or ingress rule because it is a background process, not an HTTP
endpoint. It shares /rails/storage with web because Fizzy’s queue database lives under the same storage
path in the default SQLite deployment.
Set secrets
Section titled “Set secrets”Generate app secrets locally, then store them in solo state. Prefer --stdin so values do not land in
shell history.
bin/rails secret | devopsellence secret set SECRET_KEY_BASE --service web --stdinGenerate VAPID keys using the app bundle, then set both values:
bin/rails runner 'key = WebPush.generate_key; puts key.public_key; puts key.private_key'printf '%s' '<public-key>' | devopsellence secret set VAPID_PUBLIC_KEY --service web --stdinprintf '%s' '<private-key>' | devopsellence secret set VAPID_PRIVATE_KEY --service web --stdinSet SMTP credentials if this instance will send mail:
printf '%s' '<smtp-username>' | devopsellence secret set SMTP_USERNAME --service web --stdinprintf '%s' '<smtp-password>' | devopsellence secret set SMTP_PASSWORD --service web --stdinFor the split-worker config, the worker uses the same secret names. Set them for worker too, or use the
same 1Password references for both services:
SECRET_KEY_BASE="<gener...ret>"for service in web worker; do printf '%s' "$SECRET_KEY_BASE" | devopsellence secret set SECRET_KEY_BASE --service "$service" --stdin printf '%s' '<public-key>' | devopsellence secret set VAPID_PUBLIC_KEY --service "$service" --stdin printf '%s' '<private-key>' | devopsellence secret set VAPID_PRIVATE_KEY --service "$service" --stdin printf '%s' '<smtp-username>' | devopsellence secret set SMTP_USERNAME --service "$service" --stdin printf '%s' '<smtp-password>' | devopsellence secret set SMTP_PASSWORD --service "$service" --stdindoneYou can also store solo secrets as 1Password references instead of plaintext local values:
devopsellence secret set SMTP_PASSWORD --service web --store 1password --op-ref op://deploy/fizzy/smtp-passwordAttach nodes
Section titled “Attach nodes”For an existing VM:
devopsellence node create prod-1 --host <server-ip-or-hostname> --user root --ssh-key ~/.ssh/id_ed25519devopsellence agent install prod-1devopsellence node attach prod-1For a provider-created Hetzner node:
printf '%s' "$HCLOUD_TOKEN" | devopsellence provider login hetzner --stdindevopsellence node create prod-1 --provider hetzner --install --attachDeploy
Section titled “Deploy”Check the workspace before applying changes:
devopsellence doctordevopsellence deploy --dry-rundevopsellence deploydevopsellence statusVerify the real endpoints, not just the CLI output:
curl -fsS https://fizzy.example.com/upcurl -I http://fizzy.example.com/curl -fsS https://fizzy.example.com/If TLS is still pending, run the explicit ingress readiness check and then retry the HTTPS probe:
devopsellence ingress check --wait 2mcurl -fsS https://fizzy.example.com/upOperate it
Section titled “Operate it”Useful replacements for Fizzy’s Kamal aliases:
# Rails consoledevopsellence exec web -- ./bin/rails console
# Shelldevopsellence exec web -- bash
# Web logsdevopsellence logs web --node prod-1 --lines 200
# Worker logsdevopsellence logs worker --node prod-1 --lines 200
# Database consoledevopsellence exec web -- ./bin/rails dbconsole --include-password
# Node diagnosticsdevopsellence node diagnose prod-1devopsellence node logs prod-1 --lines 200Create a redacted support bundle when handing context to another operator or agent:
devopsellence support bundle --output ./devopsellence-support.jsonNotes for production Fizzy instances
Section titled “Notes for production Fizzy instances”- Back up the VM volume that backs
fizzy_storage; it contains SQLite databases and local Active Storage files. - Use real SMTP credentials before inviting users. Passwordless login and notifications depend on mail.
- Keep
BASE_URLaligned with the public HTTPS origin. - If you enable multi-tenant signup, set
MULTI_TENANT=trueintentionally and review Fizzy’s product-level account/signup expectations. - This guide keeps the default SQLite/local-storage shape. If you later move to external object storage, MySQL, or more job workers, model each dependency explicitly instead of hiding it in shell hooks.