The Kazisafi Tech Stack

A look at the technologies powering our payroll platform - Rails 8, Hotwire, PostgreSQL, and the architectural decisions behind them.

DHH calls Rails “the one-person framework” - a toolkit powerful enough for individual developers to build modern applications without assembling complex ecosystems. The key engine is what he describes as conceptual compression: throwing away irrelevant details so you can ship without mastering every underlying technology.

Kazisafi runs on this philosophy. A small team, a Rails monolith, and the bet that we can move faster by doing less.

The Stack

Ruby 3.4.5 with Rails 8.1. Staying current matters - Rails ships with Kamal for deployment, sensible defaults, and import maps that let us skip the Node.js build step entirely.

PostgreSQL 16 for data. Redis for caching, job queues, and real-time features. Sidekiq for background processing. The boring choices that work.

The app is a monolith organized by business domain. Payroll code lives with payroll code. Leave management lives together. This keeps related things close and makes the codebase navigable.

Hotwire Everywhere

We went all-in on Hotwire instead of building a JavaScript frontend.

Turbo handles navigation and partial page updates. When someone approves a leave request, we swap just the status badge - no full page reload, no client-side state management:

<%= turbo_frame_tag dom_id(leave_request) do %>
<div class="leave-request">
<span class="leave-request__status">
<%= leave_request.status %>
</span>
</div>
<% end %>

Stimulus provides the JavaScript we actually need. About 60 controllers handle dropdowns, form validation, keyboard shortcuts, and the occasional animation. Each controller does one thing:

import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["menu"]
#isOpen = false
toggle() {
this.#isOpen = !this.#isOpen
this.menuTarget.hidden = !this.#isOpen
}
#handleClickOutside = (event) => {
if (!this.element.contains(event.target)) {
this.#close()
}
}
}

Import Maps handle JavaScript dependencies without a build step.

One Codebase, Every Platform

The same Hotwire approach extends to mobile. Turbo Native wraps the web app in native iOS and Android shells, giving us app store presence without maintaining separate codebases.

The navigation feels native because Turbo Native handles the transitions. Forms work because they’re the same forms. Push notifications plug in where needed, but most screens are just the web app rendered natively.

One codebase. Web, iOS, Android. That’s the one-person framework promise in practice.

ViewComponents for Complex UI

We use ViewComponents for UI that needs structure - components with logic, explicit interfaces, and tests:

class EmployeeCardComponent < ViewComponent::Base
def initialize(employee:)
@employee = employee
end
def status_class
@employee.active? ? "employee-card--active" : "employee-card--inactive"
end
end

Partials still handle simpler cases. Not everything needs to be a component.

Background Jobs

Scheduled jobs keep the system running. Public holidays sync automatically. Payroll reminders go out on schedule. Data cleanup runs nightly.

module Holidays
class SyncJob < ApplicationJob
queue_as :default
def perform
current_year = Date.current.year
Holidays::SyncService.new.call(year: current_year)
if Date.current.month == 12
Holidays::SyncService.new.call(year: current_year + 1)
end
end
end
end

Sidekiq processes the queue. Redis backs it. The combination handles everything from quick emails to hour-long payroll calculations.

Association Extensions

Instead of scattering query logic across the codebase, we define contextual queries on associations:

class Company < ApplicationRecord
has_many :employees do
def active
where(status: :active)
end
def payroll_ready
active.where.not(bank_account_number: nil)
end
end
has_many :todos do
def claimed_by(user)
where(started_by: user, status: :doing)
end
end
end

Then the calling code reads naturally:

company.employees.active
company.employees.payroll_ready
company.todos.claimed_by(current_user)

Deployment

Kamal deploys Docker containers to a VPS, with AWS handling the managed services (RDS, ElastiCache, S3, ECR):

service: kazisafi
image: kazisafi/app
servers:
web:
- 192.168.1.1
proxy:
ssl: true
host: dev.kazisafi.ke
registry:
server: 123456789.dkr.ecr.us-east-1.amazonaws.com
username: AWS
password: <%= %x(aws ecr get-login-password) %>

SSL termination and container registry config in one file. Kamal handles the app servers. AWS handles the data.

Observability

Better Stack for logs - it hooks into Rails 8.1’s new event API, so structured logs flow automatically. Sentry catches errors with full context. AppSignal tracks performance and alerts on anomalies.

Three services, complete visibility. When something breaks at 2am, we know about it before customers do.

That’s the stack. Technology that gets out of the way so we can focus on payroll.