English | 日本語
A clinical ledger application for a single hospital department, tracking patients, diagnoses, surgeries, and hospitalizations. It brings outpatient and ward records together in one place, with a focus on making elective slot utilization, the lifecycle of a hospitalization from request to discharge, and an audit log of every change visible under per-user permissions. The landing page is an operations calendar spanning 50 days by default, showing daily surgery and admission counts, congestion warnings, holidays, and announcements at a glance.
It is built entirely on the stock Rails 8.1 stack (Propshaft + importmap + Turbo/Stimulus) — no Node build pipeline and no external database server.
- Features
- Tech stack
- Setup
- Development commands
- Domain model
- Design notes
- Testing
- Deployment
- Development rules
- License
| Feature | Screen | Description |
|---|---|---|
| Login | /login |
Login id (email or username, per ACCOUNT_IDENTIFIER) + password authentication. Sessions expire automatically after a period of inactivity |
| Operations calendar (home) | /operations_calendar |
Day-by-day view spanning 50 days by default. Surgery and admission counts, congestion warnings, holidays / per-day comments, summaries (waiting, recently updated, needs attention), and announcements |
| Patient ledger | /patients |
Patient master data, with keyword search by name or hospital ID and pagination |
| Patient diagnoses | /patients/:id/patient_diagnoses |
Per-patient diagnosis history referencing the diagnosis master, holding the diagnosis date and laterality |
| Surgery records | /surgeries |
Surgery date (may be "undecided"), procedures (up to 5), anesthesia method, duration, elective/emergency type, operator/assistant/operation order. Linked to patient diagnoses and hospitalizations |
| Hospitalization records | /hospitalizations |
Lifecycle from reservation (scheduled admission date) through actual admission and discharge, admission purpose, administrator confirmation, soft delete/restore, and rebooking (copy) |
| Surgery slot schedule | /surgery_schedule |
Weekly calendar showing the surgeries and used time per slot, warning about slot overruns, unassigned surgeries, and holidays |
| Dashboard | /dashboard |
Statistics filterable by year (patient counts, inpatients, monthly counts, average length of stay, procedure ranking) |
| Cross-entity search | /search |
Search patients, hospitalizations, and surgeries with a single keyword |
| Audit log | /audit_events |
Records and displays create/update/delete of patients, surgeries, and hospitalizations with the operator, IP address, and before/after values |
| Master data | /diagnoses /surgery_procedures /elective_slot_rules /holidays |
Diagnosis names, procedures, per-weekday slot rules (fractional slot counts supported), holidays / per-day comments |
| Account settings | /account |
Change your own name, login id, and password, and switch the display language |
| User management (admin only) | /admin/users |
Create, edit, and deactivate users and reset passwords. The last active admin can be neither deactivated nor demoted |
| Bulk user registration (admin only) | /admin/user_import/new |
Register users from a CSV of login ids and passwords. Existing login ids are skipped, and every line is reported back as registered / skipped / failed |
| Announcement management (admin only) | /admin/announcements |
Create announcements shown on the operations calendar and toggle their visibility |
| Admin notes (admin only) | /admin/admin_notes |
Free-text handover notes shared between administrators |
| Application settings (admin only) | /admin/settings |
Change the application title shown in the navigation bar, the browser tab, and the installable app |
Authentication, user management, role separation (user/admin), access logging, and
idle timeout are implemented. Every application screen requires a logged-in user; the
exceptions are /login, the language switcher (PATCH /locale), and the /up health check.
| Area | Technology |
|---|---|
| Language / framework | Ruby 4.0.7 / Rails 8.1 |
| Database | SQLite (four schemas: the app itself, Solid Queue, Solid Cache, Solid Cable) |
| Background processing | Solid Queue / Solid Cache / Solid Cable |
| Assets | Propshaft + importmap-rails (no Node, no JS bundler) |
| CSS | Tailwind CSS + daisyUI (tailwindcss-rails) |
| Front end | Hotwire (Turbo Drive / Turbo Streams / Stimulus) |
| Testing | Minitest + fixtures; system tests with Capybara + Selenium |
| Static analysis | RuboCop (rubocop-rails-omakase), Brakeman, bundler-audit, importmap audit |
| Deployment | Kamal + Thruster (Dockerfile included) |
Prerequisites: Ruby 4.0.7 (see .ruby-version) and Bundler.
git clone https://github.com/studiome/altocumulus.git
cd altocumulus
bin/setupbin/setup installs gems, creates/migrates/seeds the database, clears logs, and then
starts the development server. To prepare everything without starting the server:
bin/setup --skip-serverAfter that, use bin/dev (via Procfile.dev it runs the Rails server and the Tailwind
watcher together).
bin/devThe app is available at http://localhost:3000. The root path is the operations calendar
(OperationsCalendarController#index); unauthenticated visitors are redirected to the
login screen.
In development, bin/setup also runs the seeds, so you can log in with the following
demo administrator account (see db/seeds.rb).
| Login ID | Password |
|---|---|
admin@example.com |
password |
In production, setting both of the environment variables below on first boot creates
a bootstrap administrator account (named Administrator) when db:seed runs. If a user
with the same login id already exists, nothing happens.
| Environment variable | Description |
|---|---|
BOOTSTRAP_ADMIN_LOGIN_ID |
Login id (email or username, depending on ACCOUNT_IDENTIFIER) of the first administrator |
BOOTSTRAP_ADMIN_PASSWORD |
Password of the first administrator |
BOOTSTRAP_ADMIN_EMAIL is kept as an older name for the same variable: it is still read as
a fallback when BOOTSTRAP_ADMIN_LOGIN_ID is unset.
The following environment variables tune runtime behavior (config/application.rb).
| Environment variable | Description | Default |
|---|---|---|
ACCOUNT_IDENTIFIER |
Whether an account logs in with an email address or a username. Decide this when first setting up the server and do not change it afterward -- switching modes later makes existing login ids fail the new format validation. |
email |
SESSION_IDLE_TIMEOUT_MINUTES |
Session idle timeout, in minutes | 10 |
ADMISSION_WARNING_THRESHOLD |
Admissions per day above which the operations calendar flags congestion | 5 |
APP_TITLE |
Application title used before an administrator sets one on /admin/settings. Useful for branding a fresh deploy without signing in first |
Altocumulus |
bin/dev # dev server + Tailwind watcher
bin/rails test # all tests except system tests
bin/rails test test/models/patient_test.rb # a single file
bin/rails test test/models/patient_test.rb:12 # a single test at a line
bin/rails test:system # system tests (requires Selenium)
bin/rails db:prepare # create / migrate / seed the database
bin/rubocop # lint
bin/brakeman # static security analysis
bin/bundler-audit # audit gems for known vulnerabilities
bin/ci # the same checks CI runs (config/ci.rb)bin/ci runs setup → RuboCop → the gem/importmap/Brakeman audits → tests → a re-run of
the seeds. GitHub Actions (.github/workflows/ci.yml) runs an equivalent job on pushes
to main and on pull requests.
erDiagram
Patient ||--o{ PatientDiagnosis : "diagnosis history"
Patient ||--o{ Surgery : "surgeries"
Patient ||--o{ Hospitalization : "hospitalizations"
Diagnosis ||--o{ PatientDiagnosis : "reference"
Diagnosis ||--o{ HospitalizationDiagnosis: "reference"
Hospitalization ||--o{ HospitalizationDiagnosis: ""
Hospitalization ||--o{ Surgery : "surgeries during stay"
Surgery ||--o{ SurgeryDiagnosisLink : ""
PatientDiagnosis ||--o{ SurgeryDiagnosisLink : ""
Surgery ||--o{ SurgeryProcedureSelection : ""
SurgeryProcedure ||--o{ SurgeryProcedureSelection : "reference"
User ||--o{ AuditEvent : "operator"
Patient {
string hospital_id UK
string name
date date_of_birth
}
PatientDiagnosis {
date diagnosed_on
string laterality "none/left/right/bilateral"
}
Surgery {
date surgery_date "null when undecided"
time start_time
float duration_hours
string anesthesia_method
string scheduling_type "elective/emergency"
int slot_number "slot it occupies / null when unassigned"
int operation_order "order within the day"
string operator_name
string assistant_name
}
Hospitalization {
date scheduled_admission_date "planned date (reservation stage)"
date admission_date "actual date"
date discharge_date
string reservation_status "requested/waiting/date_fixed/.../discharged"
string purpose "surgery/examination/chemotherapy"
string admin_status "unconfirmed/confirmed"
string outcome
string discharge_destination
int planned_days
datetime deleted_at "soft delete"
string patient_name_snapshot "patient snapshot at reservation time"
}
SurgeryProcedureSelection {
string laterality
}
User {
string login_id UK
string role "user/admin"
string locale "en/ja"
boolean active
}
AuditEvent {
string auditable_type "Patient/Surgery/Hospitalization"
string action "create/update/destroy"
string ip_address
json change_data
}
In addition to the above there are ElectiveSlotRule (per-weekday slot count and minutes
per slot; the count may be fractional), Holiday (holidays and per-day comments),
Announcement, AdminNote, and AccessLog (login/logout/timeout/failed authentication).
Key constraints:
Diagnosis/SurgeryProcedureuserestrict_with_error: master records in use cannot be deleted.- A
Surgeryholds 1–5 procedures with no duplicates. Linked patient diagnoses must belong to that surgery's patient.surgery_datemay be "undecided", in which case it is stored asnull. - A
Hospitalizationrequires at least one diagnosis, with no duplicates. At the reservation stage it can be saved with onlyscheduled_admission_date(admission_dateis not required), but at least one of the two must be present. Overlapping stays for the same patient are rejected based on whichever date is effective (actual, otherwise scheduled). - Deleting a
Hospitalizationis a soft delete (deleted_at) and can be undone. An update by a regular user forcibly resetsadmin_statustounconfirmed; only an administrator can set it toconfirmed. - To link a
Surgeryto a hospitalization, both must belong to the same patient and the surgery date must fall within the stay. Surgery#slot_number/#operation_orderare integers of 1 or more (nullwhen unassigned). Exceeding the slot count or a slot's time budget does not block saving; it surfaces as a warning on the schedule board and the operations calendar.- A
Usercannot deactivate or demote the last active administrator (cannot_deactivate_or_demote_last_admin).
One slot = one operating room's time block for that day. A slot holds several surgeries
(2–3 are expected), and the slot is flagged as an overrun when the sum of the durations of
the surgeries in it exceeds the slot duration (slot_duration_minutes). The check is per slot,
not per surgery.
Which slot a surgery occupies is stated explicitly via Surgery#slot_number — there is no
automatic assignment. An elective surgery with no slot number, or one pointing past that day's
slot count, does not enter a slot and is listed under "Not assigned to a slot" at the bottom of
the board.
A holiday disables that day's elective slots. Emergency surgeries are outside the slot rules
and affect neither utilization nor warnings. If an emergency surgery is given a slot number, the
save is not rejected: before_validation silently clears it, because an emergency surgery must
always be saveable.
Main objects:
| Element | Role |
|---|---|
ElectiveSlotUsage |
Slot usage for one day (a PORO). Provides slots / unscheduled_surgeries / warnings |
ElectiveSlotUsage::Slot |
A single slot. surgeries / used_minutes / remaining_minutes / overrun? / overrun_minutes |
ElectiveSlotRule |
Per-weekday slot count (slot_count) and minutes per slot (slot_duration_minutes) |
ElectiveSlotUsage.for_dates builds a whole week's — or the whole operations calendar's — worth
of usage with a fixed number of queries regardless of the day count, avoiding N+1.
slot_count allows fractions (e.g. 2.5). The integer part is the number of full-length
slots, and the fraction means only the last slot of the day is shorter
(see ElectiveSlotRule#slot_durations / #total_slots). When counting slots, always use
total_slots (slot_count.ceil) rather than slot_count itself: a range such as
(1..slot_count) silently drops the last short slot, because Ruby truncates a fractional endpoint.
Login uses has_secure_password (bcrypt), with two roles: user and admin.
ApplicationController enforces require_login before every action (only pre-login screens such
as /login opt out via skip_before_action), and require_admin gates the admin-only screens
(user management, announcement management, admin notes, and hospitalization
confirm/restore/rebook).
The session stores the time of last access; going longer than config.x.session_idle_timeout
(10 minutes by default, configurable via SESSION_IDLE_TIMEOUT_MINUTES) without activity expires
the session and records a timeout event in AccessLog. Successful and failed logins and logouts
are recorded the same way (with IP address, User-Agent, and requested URL). A failed login does not
distinguish between an unknown login id and a wrong password, and authenticate_by performs a dummy
BCrypt comparison so the two cannot be told apart by response time either.
User also validates that the last active administrator cannot be deactivated or demoted,
preventing the system from ending up with no administrator.
Hospitalization models the "reserved → fixed → admitted → discharged" timeline by keeping actual
and planned dates apart.
- On creation it can be saved with only
scheduled_admission_date;admission_datemay still be undecided. Only when both are blank is the save rejected. reservation_status(requested/waiting/date_fixed/surgery_date_fixed/admitted/ admitted_other_dept/on_hold/discharged) tracks the reservation's progress, andpurpose(surgery/examination/chemotherapy) the reason for admission.admin_status(unconfirmed/confirmed) is forced back to unconfirmed on every update by a regular user; only an administrator can set it to confirmed via theconfirmaction (before_update :reset_admin_status_for_non_admin_update). Saves without aCurrent.user(console, seeds) are exempt.- Deletion is a soft delete via
deleted_at(discard!/restore!) rather than a physical one, and records can be restored from the deleted list (/hospitalizations/deleted, admin only). #rebookperforms a rebooking (copy) for "take this again on a different planned date": it creates a newrequestedrecord carrying over only the diagnoses, dropping the actual dates, discharge information, administrator confirmation, and surgery links (/hospitalizations/:id/copy).- The patient's name, age, and sex at the time of reservation are kept as a snapshot
(
patient_name_snapshotand friends), so later changes to the patient do not alter the record as it stood then.
Hospitalization and Surgery use accepts_nested_attributes_for to add and remove several join
records in a single submit. The matching Stimulus controllers clone a <template> to add rows, and
removal toggles a hidden _destroy field without disturbing the DOM structure the server rendered.
Because Rails' params.expect treats only numeric keys as nested-attribute indices, newly added
rows must use a monotonically increasing number (based on Date.now()) as their child_index.
An update that swaps the values of two existing rows can transiently violate a unique index
mid-save, so the controllers rescue ActiveRecord::RecordNotUnique and turn it into a validation
error.
Patients, diagnosis names, procedures, and a surgery's related patient diagnoses are all chosen in a
modal holding a lazily loaded turbo-frame -- searchable and paginated -- rather than in a <select>.
The field itself is a hidden input plus a read-only display that the picked record fills in.
A new diagnosis name or procedure can also be created from inside that same modal, without leaving the form being filled in: on success the picker frame is replaced by a confirmation that immediately selects the newly created record on the row that opened the modal.
UserImport takes a CSV an admin uploads on /admin/user_import/new. The header row must contain
login_id and password; name, role and locale are optional, and a row with no name is
registered under its login id. The same screen offers a blank template to start from
(GET /admin/user_import/template): the header row alone, led by a UTF-8 BOM so Excel on a Japanese
system opens it correctly. It deliberately carries no example rows -- a template filled in around
its samples would register them as real users.
Rows are saved one at a time rather than in a single transaction, so one bad line does not throw away the rest of the file: every line comes back as registered, skipped, or failed with the validation message (never with the password in it). A login id that already exists is skipped and left untouched -- overwriting a colleague's password from a stale spreadsheet is the mistake this screen is built to avoid.
The name in the navigation bar, the browser tab, and the PWA manifest comes from AppSetting, which
an administrator edits on /admin/settings. When nothing has been saved there, it falls back to the
APP_TITLE environment variable read at boot, and then to the built-in name -- so a fresh deploy can
be branded with an environment variable alone.
The Auditable concern is included in Patient / Surgery / Hospitalization and records
creates, updates, and deletes as AuditEvents. change_data stores the before/after values and
record_label stores each model's to_s, so a change remains traceable even after the record is
deleted. The Current.user (operator) and Current.ip_address (request origin) at the time of the
change are stored as well, so it is clear "who" changed something and "from where".
If recording an audit event fails, the whole save is rolled back, so nothing goes unrecorded.
Because update_all skips callbacks, unlinking surgeries when a hospitalization is deleted is done
with per-record saves.
The language can be switched between Japanese and English at any time from the navigation
(English is the default). While logged in, the choice is persisted to users.locale and carried
over to later logins; when logged out it is kept temporarily in the cookie session
(LocalesController). params[:locale] only accepts values contained in I18n.available_locales,
so an arbitrary string is never passed straight to I18n.locale=.
Values stored in the database (outcome / reservation_status / purpose / role, and so on)
always stay as English keys. Only the display labels are translated — each model's *_options /
*_form_options methods look the labels up through I18n.t on the spot. As a result, switching
languages later never changes the meaning of stored data, and the list of keys is maintained
entirely in config/locales/*.yml.
Development follows Red/Green TDD. Tests are Minitest + fixtures.
bin/rails test # model, controller, and integration tests (no system tests)
bin/rails test:system # Capybara + Seleniumtest/ is split into models / controllers / integration / system / helpers / views.
The fixtures (test/fixtures/) deliberately encode cases such as "a day where two surgeries fit in
one slot", "a day where one slot overruns", and "a surgery with no slot assigned", so check the
comments before changing them.
Docker-based deployment with Kamal + Thruster is supported (Dockerfile, config/deploy.yml,
.kamal/).
bin/kamal setup # first time
bin/kamal deploy # afterwardsThe health check is exposed at /up (rails/health#show).
If BOOTSTRAP_ADMIN_LOGIN_ID (or the older BOOTSTRAP_ADMIN_EMAIL) / BOOTSTRAP_ADMIN_PASSWORD are set, the first administrator account is
created automatically on the first deploy, through the db:prepare (plus db:seed when the
database is created fresh) run by bin/docker-entrypoint at container start. See Setup
for details.
This configuration assumes a single web server: SOLID_QUEUE_IN_PUMA runs the Solid Queue
supervisor inside the same Puma process, and config/deploy.yml bind-mounts SQLite's storage
directory from a specific host path (/srv/altocumulus/storage) rather than a shared volume.
config/master.key. .kamal/secrets reads RAILS_MASTER_KEY=$(cat config/master.key),
but config/*.key is gitignored and isn't part of the repository. If you already hold the key,
place it at config/master.key (chmod 600). Otherwise, this app stores nothing of its own in
credentials (only secret_key_base), so it's fine to generate a fresh one:
rm config/credentials.yml.enc
EDITOR=true bin/rails credentials:editRegenerating it against an environment that's already live changes secret_key_base and
invalidates every existing session (users just need to log in again; no data is lost).
Placeholders in config/deploy.yml. The file still has the Rails template's placeholders:
servers.web's 192.168.0.1 needs the real server's host/IP. registry.server's
localhost:5555, however, does not need to change: Kamal treats any registry.server matching
localhost[:port] as "local registry mode" (Registry#local?), and in that mode it starts a
registry:3 container (kamal-docker-registry) on the build machine bound to 127.0.0.1:<port>,
adds --driver-opt network=host to the buildx builder, and forwards that same port to every
server over an SSH remote tunnel -- which is what lets the server's own docker pull localhost:5555/... resolve back to the build machine. username / password are only required
once registry.server stops matching localhost. So in the common case where the machine you
build from and the servers you deploy to are different machines, localhost:5555 works untouched;
switching to a real registry (ghcr.io, Docker Hub, a self-hosted one, ...) is only needed if you
actually want the image kept in a shared registry. If you're using a custom domain with Let's
Encrypt, uncomment the proxy: block -- and also uncomment config.assume_ssl and
config.force_ssl in config/environments/production.rb (lines 28 and 31), since enabling only
one of the two causes a redirect loop, as the deploy.yml comment notes.
Add the bootstrap admin variables to env:. db/seeds.rb reads these from the container's
ENV, but Kamal only forwards variables listed under env: in config/deploy.yml. They aren't
there yet, so deploying as-is creates no administrator and leaves nobody able to log in:
env:
secret:
- RAILS_MASTER_KEY
- BOOTSTRAP_ADMIN_PASSWORD
clear:
SOLID_QUEUE_IN_PUMA: true
BOOTSTRAP_ADMIN_LOGIN_ID: admin@example.org
ACCOUNT_IDENTIFIER: email # set to "username" instead if you prefer
SESSION_IDLE_TIMEOUT_MINUTES: 10Add the secret to .kamal/secrets too (never the raw value):
BOOTSTRAP_ADMIN_PASSWORD=$BOOTSTRAP_ADMIN_PASSWORDAs the Setup table notes, decide ACCOUNT_IDENTIFIER now and don't change it later.
Prepare the server. Create the bind-mounted storage directory, owned by the container's
rails user:
mkdir -p /srv/altocumulus/storage
chown 1000:1000 /srv/altocumulus/storageInstall the backup script too -- see Database backup below. The machine you
deploy from also needs ssh access to the server, since both Kamal itself and
.kamal/hooks/pre-deploy use it.
.kamal/hooks/pre-deploy backs up the database on every server before each deploy by running
/usr/local/bin/altocumulus-backup over ssh, but bin/backup-db exits with an error when the
database doesn't exist yet. On the very first deploy there's no database yet, so the hook fails
and kamal setup aborts unless the backup is skipped for that one run:
export BOOTSTRAP_ADMIN_PASSWORD='...'
SKIP_PRE_DEPLOY_BACKUP=1 bin/kamal setupFrom then on, use bin/kamal deploy (which runs the pre-deploy backup automatically). To check
the deploy succeeded:
curl -f https://<host>/up
bin/kamal logs -fA few operational commands are predefined as aliases in config/deploy.yml:
| Command | Purpose |
|---|---|
bin/kamal logs |
Tail application logs |
bin/kamal console |
Open a Rails console on the server |
bin/kamal shell |
Open a shell in the running container |
bin/kamal dbc |
Open a rails dbconsole |
bin/kamal app stop / bin/kamal app boot |
Stop / start the app container |
bin/kamal rollback <version> |
Roll back to a previous version |
rollback skips the pre-deploy backup -- the hook checks KAMAL_COMMAND and exits early for it.
To try this out on your own machine, or to run a permanent single-server setup where the box you build on and the box you deploy to are the same, use a separate Kamal destination alongside a few settings that only matter in that specific case.
Keep production config untouched with a destination. config/deploy.<name>.yml gets merged
into config/deploy.yml, and bin/kamal deploy -d <name> picks it up. When a destination is
given, Kamal's secrets loader only reads .kamal/secrets-common and .kamal/secrets.<name> --
never the production .kamal/secrets (Kamal::Secrets#secrets_filenames). Gitignore both files.
SSH is still required, even against localhost. Kamal always talks to servers over SSH, even
when the server is the machine it's run from. Passwordless login has to work: generate a
dedicated key, add it to authorized_keys, and point ssh.user / ssh.keys / ssh.keys_only at
it.
Pitfall 1: local registry mode collides with itself. Leaving registry.server as
localhost:5555 makes Kamal SSH-remote-forward 127.0.0.1:5555 to the server. When the server is
the same machine you build on, that port is already held by the registry container itself, so
sshd can't bind it and the deploy fails with Failed to establish port forward on <host>. Work
around it by running your own registry on an address that does not match ^localhost[:$], such
as 127.0.0.1:5555 -- Docker treats 127.0.0.0/8 as an insecure registry by default, so no
daemon configuration change is needed. Kamal still requires username / password once the
address isn't localhost, but a registry with no auth configured accepts any credentials:
docker run -d --name altocumulus-local-registry --restart unless-stopped \
-p 127.0.0.1:5555:5000 registry:3Pitfall 2: buildx can't reach the registry. Kamal only adds --driver-opt network=host when
the registry is localhost:.... With 127.0.0.1:5555, the default docker-container builder
looks for the registry on its own container's loopback interface, and the push fails with
connection refused. Work around it by setting builder.driver: docker to build directly against
the host's Docker daemon -- you lose build caching and multi-arch builds, but neither matters for
a single-machine setup.
Pitfall 3: kamal-proxy routes by Host header. Any request whose Host header isn't listed in
proxy.host gets a 404. List every name/address you might type into a browser -- localhost,
the LAN IP, the hostname, a Tailscale name, ... -- comma-separated. Omitting host turns the
proxy into a catch-all that accepts every Host instead.
Port 80. kamal-proxy publishes the host's ports 80/443. Stop any system Apache/nginx first, or it won't be able to bind.
Skipping master.key entirely. Rails reads SECRET_KEY_BASE from the environment in
production and only falls back to credentials when it's absent. For a local destination, list
SECRET_KEY_BASE under env.secret and never touch config/credentials.yml.enc at all. Generate
a value with bin/rails secret.
The pre-deploy backup hook still runs every time. Not just on the first deploy -- it runs
ssh root@<host> /usr/local/bin/altocumulus-backup before every deploy. If the local destination
doesn't have that script installed, pass SKIP_PRE_DEPLOY_BACKUP=1 every time (or install the
script and adjust BACKUP_SSH_USER / BACKUP_REMOTE_SCRIPT -- see Database
backup below).
Putting it together, an example config/deploy.local.yml:
servers:
web:
- 127.0.0.1
registry:
server: 127.0.0.1:5555
username: kamal
password:
- KAMAL_REGISTRY_PASSWORD
ssh:
user: youruser
keys_only: true
keys:
- "~/.ssh/id_ed25519_kamal_local"
builder:
driver: docker
arch: amd64
proxy:
ssl: false
host: localhost,192.168.1.10,yourhost.local
app_port: 80
volumes:
- "/home/youruser/altocumulus-storage:/rails/storage"
env:
secret:
- SECRET_KEY_BASE
- BOOTSTRAP_ADMIN_PASSWORD
- KAMAL_REGISTRY_PASSWORD
clear:
SOLID_QUEUE_IN_PUMA: true
ACCOUNT_IDENTIFIER: email
BOOTSTRAP_ADMIN_LOGIN_ID: admin@example.orgSKIP_PRE_DEPLOY_BACKUP=1 bin/kamal setup -d local # first time
SKIP_PRE_DEPLOY_BACKUP=1 bin/kamal deploy -d local # afterwardsSecurity note. This setup serves plain HTTP: SSL is disabled because Let's Encrypt only works
for a real domain name, not for localhost or a LAN name/IP. Exposing it on a LAN sends login
credentials and patient data unencrypted. Treat it as a way to verify things work, not a place to
put real data.
To try a plain Docker build without Kamal (matching the comment at the top of Dockerfile, with
the extra environment variables filled in):
docker build -t altocumulus .
mkdir -p /srv/altocumulus/storage && chown 1000:1000 /srv/altocumulus/storage
docker run -d --name altocumulus -p 80:80 \
-v /srv/altocumulus/storage:/rails/storage \
-e RAILS_MASTER_KEY="$(cat config/master.key)" \
-e SOLID_QUEUE_IN_PUMA=true \
-e ACCOUNT_IDENTIFIER=email \
-e BOOTSTRAP_ADMIN_LOGIN_ID=admin@example.org \
-e BOOTSTRAP_ADMIN_PASSWORD='...' \
altocumulusThere's no SSL termination here, so put nginx/Caddy in front in production and enable
assume_ssl / force_ssl accordingly.
Rails opens SQLite in WAL mode, so copying production.sqlite3 with a plain cp while the app is
running can produce a broken backup (recent commits may still be sitting in the -wal file).
bin/backup-db instead uses SQLite's Online Backup API (sqlite3 <db> ".backup '<dest>'"), which
stays consistent even while the app is writing.
Only storage/production.sqlite3 is backed up. The cache/queue/cable databases are regenerable
(this app defines no jobs of its own — see config/recurring.yml) and don't need backing up.
config/deploy.yml bind-mounts the storage directory to a host path (/srv/altocumulus/storage)
rather than a named Docker volume, so the backup script can run directly on the host.
Install it on the server and run it from cron:
sudo install -m 755 bin/backup-db /usr/local/bin/altocumulus-backup
# crontab -e
0 2 * * * BACKUP_GPG_RECIPIENT=backup@example.org /usr/local/bin/altocumulus-backup >> /var/log/altocumulus-backup.log 2>&1Environment variables:
| Variable | Default | Meaning |
|---|---|---|
BACKUP_SRC |
/srv/altocumulus/storage/production.sqlite3 |
Path to the source database |
BACKUP_DEST_DIR |
/srv/backup/altocumulus |
Directory to write backup artifacts into |
BACKUP_KEEP |
30 |
Number of most recent artifacts to retain (older ones are deleted) |
BACKUP_GPG_RECIPIENT |
(unset) | GPG recipient (key ID/email) to encrypt the backup for; encryption is skipped when unset |
Since this data is patient data, always set BACKUP_GPG_RECIPIENT so backups are encrypted at
rest, and rsync the encrypted artifacts to a separate machine or NAS — not just the same server's
disk.
.kamal/hooks/pre-deploy runs the backup on every server before each deploy automatically. Set
SKIP_PRE_DEPLOY_BACKUP=1 to skip it for a given deploy.
To restore:
bin/kamal app stop
gpg -d production-20260909T020000Z-123.sqlite3.gz.gpg | gunzip > /srv/altocumulus/storage/production.sqlite3
rm -f /srv/altocumulus/storage/production.sqlite3-wal /srv/altocumulus/storage/production.sqlite3-shm
chown 1000:1000 /srv/altocumulus/storage/production.sqlite3
bin/kamal app bootForgetting to remove the -wal/-shm files can let a stale WAL overwrite the data you just
restored. Also make sure the app version running matches the one the backup was taken from —
restoring an old database under a newer version can break on unapplied migrations.
A daily snapshot can lose up to 24 hours of data. To tighten the recovery point objective, pair this with continuous replication via Litestream (e.g. to a file replica on a NAS, or to an S3-compatible target like MinIO).
The biggest risk with backups is not noticing when they stop working: monitor and alert on the cron job's output, monitor the freshness of the most recent backup artifact, and periodically rehearse an actual restore.
The conventions for the development flow, including AI agents, are documented here.
- AGENTS.md — shared rules for agents (replies in Japanese, TDD, Git workflow)
- CLAUDE.md — repository-specific guidance for Claude Code
In short:
- Answers and reports in Japanese; commit messages in English.
- Red/Green TDD: write a failing test first, then the minimum implementation to pass it.
- Commit directly to
mainas a rule; open a pull request only when explicitly asked.
MIT License — Copyright (c) 2026 Kazuhiro Miyahara