A Rails 7.0.8 application built to look like a pre-container, VM-hosted system, then migrated to Docker and Kubernetes. It exists to practice and demonstrate that migration path end to end: containerizing a legacy Rails app, splitting it into web/worker/cron workloads, and running it on Kubernetes with proper health checks, config/secret separation, and one-shot database migrations.
This is a fixture, not a production system. The "before" state (VM-era configuration, no traffic, no real users) was written deliberately so the "after" state could be built and verified against something concrete. See Project background for the reasoning and the exact boundary of what this project does and doesn't demonstrate.
- Ruby on Rails 7.0.8, Sprockets, PostgreSQL, Redis
- Sidekiq for background jobs, Active Storage (disk-backed) for file uploads
- Docker (multi-stage build) and Docker Compose
- Kubernetes (
kindfor local development), plain manifests underk8s/
Two ways to run this, depending on what you're trying to exercise.
docker compose up --build -d
docker compose run --rm web bin/rails db:migrate
curl http://localhost:3000/healthTear down with docker compose down (add -v to also remove the database/storage volumes).
Requires kind, kubectl, and optionally k9s for an interactive dashboard:
brew install kind kubectl k9sBring the cluster up and deploy everything:
kind create cluster --name kygo
docker build -t kygo:dev .
kind load docker-image kygo:dev --name kygo
kubectl create secret generic kygo-secrets \
--from-literal=SECRET_KEY_BASE="$(docker run --rm kygo:dev bin/rails secret)" \
--from-literal=DATABASE_URL=postgres://kygo:kygo@postgres:5432/kygo_production
kubectl apply -k k8s/overlays/local
kubectl logs job/migrateCheck status and reach the app:
kubectl get pods
kubectl port-forward svc/web 3000:3000 # then, in another shell:
curl http://localhost:3000/healthTear down with kind delete cluster --name kygo.
Note:
kindclusters run as a single Docker container and do not survive a Docker Desktop restart. Ifkubectl get nodesreturns a connection error, the cluster is gone — re-run the steps above fromkind create cluster(the Secret must always be recreated imperatively; a Job's spec is immutable, so re-running migrations after a rebuild requireskubectl delete job migratefirst).
app/ Rails application code
config/ Rails configuration, plus VM-era reference files (see below)
db/migrate/ Schema migrations
k8s/ Kubernetes manifests, structured with Kustomize
base/ Shared resources: ConfigMap, Postgres, Redis, web, worker, migration Job, CronJob
overlays/local/ Local-dev patch (web replicas: 1) — what ArgoCD is currently pointed at
overlays/production/ Production-shaped patch (web replicas: 3), not deployed anywhere in this project
puppet/manifests/ VM-era reference file (see below)
Dockerfile Multi-stage build: build stage compiles native extensions, final stage is runtime-only
docker-compose.yml Postgres + Redis + web + worker, for local container testing
This app was written in the shape of a pre-Kubernetes Rails deployment on purpose, to have a concrete "before" to migrate from:
config/god.rb— a God process-supervisor config for Puma and Sidekiq. Not installed or run in this project; kept as a reference for what a container runtime's restart policy and liveness probes replace.config/deploy.rb— a Capistrano deploy script. Not run; kept as a reference for what a Docker build + GitOps sync replaces.config/schedule.rb— awhenevercron schedule. Not run; kept as a reference for what a KubernetesCronJobreplaces.puppet/manifests/app.pp— a Puppet manifest stub. Not run; kept as a reference for what container images replace.
None of the above are wired into any executable path in this repository. They exist purely as artifacts to point at when discussing what a VM-to-Kubernetes migration actually changes.
The commit history is intentionally structured as a migration narrative: an initial commit representing the VM-era "before" state, followed by commits that containerize the app and then move it onto Kubernetes.
Scope boundary: this project demonstrates the mechanics of a containerization and Kubernetes migration — Dockerfile design, health checks, workload separation, config/secret handling, one-shot migration jobs. It does not demonstrate the operational side of migrating a live system with real traffic: safe cutover, rollback strategy, or decommissioning existing infrastructure. Those are a different (and, in practice, usually harder) problem than the one this repository solves.
The table below maps each VM-era artifact in this repo to what replaced it, and where in the repo to point at for each side.
| VM era (this repo's "before") | Kubernetes (this repo's "after") |
|---|---|
config/god.rb supervising Puma + Sidekiq |
Deployment replicas, container restart policy, liveness probes (k8s/base/web.yaml, k8s/base/worker.yaml) |
config/deploy.rb (Capistrano cap deploy) |
Docker image build + git commit; ArgoCD syncs the cluster to match git (argocd/application.yaml) |
config/schedule.rb (whenever cron syntax) |
CronJob (k8s/base/cron.yaml) |
puppet/manifests/app.pp (config management) |
The Docker image itself — the build is the environment definition now (Dockerfile) |
| One process on one VM doing everything | Three workloads off one image, differing only in command: web (Puma), worker (Sidekiq), cron (CronJob) |
Uploads on local disk (storage/, single VM) |
Shared PersistentVolumeClaim across pods — itself flagged below as a kind-only stand-in for real object storage |
| Manually editing config on the box | ConfigMap for non-secret env vars (k8s/base/configmap.yaml) |
.env / secrets.yml written to disk on the VM |
Secret, created imperatively, never committed to git — see the note on sealed-secrets below |
SSH in and run RAILS_ENV=production rails db:migrate |
One-shot Job (k8s/base/migrate-job.yaml), run once per deploy, not on pod boot |
| One fixed environment (prod on the VM) | k8s/base/ + Kustomize overlays (overlays/local, overlays/production) — same manifests, environment-specific patches |
| "Who deployed what, when" lives in shell history / memory | git log on the manifests repo is the deploy history; commit SHA is the image tag |
Two follow-up questions this table doesn't answer on its own, worth having ready:
- Who bumps the image tag after a build? CI (GitHub Actions) builds the
image, pushes it to GHCR tagged with the commit SHA, then commits that
tag into
k8s/base/*.yamlitself (.github/workflows/deploy.yml). ArgoCD only ever syncs manifests — it has no opinion about image builds. - What about the Secret sitting unencrypted outside git? Sealed-secrets
or SOPS solves this by encrypting the Secret so the ciphertext can live
in git safely and only the cluster can decrypt it. Deliberately not
built here — evaluated and scoped out, see
IDEA.mdfor the related idea of building a minimal secrets manager instead of adopting one, to understand the mechanism rather than just the tool.
A few things surfaced while building this that were more instructive than anything planned up front — noted here because they're the kind of detail that's easy to state abstractly but only really sinks in after hitting it.
A shared filesystem between pods is not "free" the way it is in Docker
Compose, and the obvious fix doesn't generalize. docker-compose.yml
shares a single named volume between web and worker, so Active
Storage's local-disk uploads just work — any container can read what any
other container wrote. Kubernetes has no equivalent by default: web and
worker are separate pods with separate, ephemeral filesystems. Uploading
a file through web and having worker try to process it (a background
ThumbnailJob generating an image variant) reliably failed with
ActiveStorage::FileNotFoundError, because the file only ever existed on
web's own disk.
The fix that comes to mind first — a shared PersistentVolumeClaim
mounted into both Deployments — does work, and is what this repository
currently uses. But it only works because this cluster is single-node
(kind). A PersistentVolumeClaim with the default access mode
(ReadWriteOnce) can only be mounted by pods scheduled onto the same
node; on a real multi-node cluster, web and worker landing on
different nodes would break this exact setup the same way local disk
did originally. Most cloud block storage (the thing a PersistentVolume
is usually backed by in production) doesn't support the access mode
(ReadWriteMany) that would actually fix this properly across nodes.
The real fix is to stop using local/block storage for this kind of
shared, multi-writer state at all, and move Active Storage onto an
object storage backend (S3-compatible; MinIO for a self-hosted
equivalent) — which was deliberately not done here, to keep this
project's infrastructure dependencies local-only.
The practical takeaway: the correctness of "pods can share files" in Kubernetes depends entirely on cluster topology, not just on whether a volume is attached. A fix that looks complete in local testing can be silently node-count-dependent.
A health check passing doesn't mean the feature it's protecting
works. /health only executes SELECT 1 against Postgres — it never
touches application tables. That was deliberate (a health check that
queries real business data is usually the wrong kind of health check,
since it couples liveness to data state), but it meant web's liveness
and readiness probes stayed green through an entire period where every
migration-dependent feature (/documents) was returning 500 PG::UndefinedTable, because migrations for a fresh Postgres pod hadn't
been re-run yet. The lesson generalizes: a passing probe is evidence the
process is alive and can reach its declared dependency, not evidence the
application is actually functional end to end.