Financial management system used daily by staff at a real family business. The primary user is non-technical: a regression in production is not an abstract bug — it disrupts the working day, the cash closing and inventory control.
The system already had ~296 tests and a daily backup configured. But nothing prevented broken code from going live: Render deployed directly on every git push, without running a single test. The protection existed only on paper.
The daily cash closing took about an hour because it was done manually, reconciling records from different payment methods without a consolidated view. With the till module, the same process now takes ~10 minutes.
Django with CBVs and a layered architecture (View → Service → Selector → Model); PostgreSQL on Neon (serverless, no server to manage); hosted on Render; GitHub Actions orchestrating lint, tests and deployment; daily backup via scheduled GitHub Actions workflow — pg_dump from Neon's direct endpoint → gzip → rclone → Google Drive → rotation of the 15 most recent files. No artefacts persist on the ephemeral runner.
CI/CD pipeline with deployment gate
- Problem
- Broken code could go live. A push to main triggered an immediate deploy on Render, without running lint or tests. With a non-technical user depending on the system, any visible regression became an incident.
- Alternatives
- Manual discipline: run tests locally before every push. Works until the day it doesn't. Deploy with toggle: disable auto-deploy and deploy manually — swaps one automation for a more fragile one.
- Choice
- GitHub Actions pipeline: push/PR → Ruff (lint) → pytest against real Postgres → 80% coverage gate → only with green CI does the deploy job call Render's Deploy Hook. Branch protection on main makes checks mandatory and blocks direct pushes. autoDeploy: false in render.yaml disables push-triggered deploys. Central rule: deployment only happens after green CI.
- Result
- No deployment happens with a red CI. Main is always in a deployable state. The pipeline is version-controlled in the repository — anyone reading the code understands exactly what must pass before going to production.
Real PostgreSQL in CI, not SQLite
- Problem
- Tests that pass on SQLite can fail on PostgreSQL due to type differences, constraint behaviour, Postgres-specific migrations or SQL SQLite does not support. That kind of bug would only surface in production.
- Choice
- Ephemeral PostgreSQL service container on the GitHub Actions runner. It starts alongside the test job, passes a health check before tests run, and is destroyed at the end. Mirrors the exact production database (Neon/Postgres).
- Result
- ~296 tests running against real PostgreSQL on every push. No production-only database bugs since the change. The CI validates the same environment the user runs on.
Daily backup via GitHub Actions, not server cron
- Problem
- The original roadmap called for cron + rclone on an always-on server. On Render free that does not exist: the instance hibernates on idle, the filesystem is ephemeral and there is no cron support. Losing financial data without a backup is unacceptable.
- Alternatives
- Upgrade to a paid Render plan (recurring cost with no technical benefit). Periodic manual backups (human reliability, not system reliability). Separate VPS just for cron (infrastructure to solve a lack of infrastructure).
- Choice
- Scheduled GitHub Actions workflow (cron: '0 3 * * *'): pg_dump with Neon's direct endpoint (no PgBouncer pooler) → gzip → minimum size validation → rclone to Google Drive → upload validation → rotation keeping the 15 most recent files. Nothing persists on the ephemeral runner. Validated in production on 17/07/2026.
- Result
- Automated daily backup with no extra server to maintain. Automatic rotation of 15 files. Every GitHub Actions run is logged and auditable — no dependency on additional infrastructure.
Layered architecture documented before writing code
- Problem
- Without clear layer boundaries, business logic migrates into views, tests become coupled to the HTTP layer, and any logic change requires edits in multiple places.
- Choice
- View → Service (write) / Selector (read) → Model. Views contain no business logic. Services encapsulate write logic with transactions. Selectors isolate complex queries. Naming rules, pagination, indexes and validation conventions written in PROJECT_RULES.md before the first model.
- Result
- Service tests with no dependency on HTTP request/response. Query refactoring without touching views. New modules follow the same pattern without additional instruction.
pg_dump 16 incompatible with Neon PostgreSQL 18
- Symptom
- The backup workflow failed with a version incompatibility error: the Ubuntu runner came with pg_dump 16, but Neon ran PostgreSQL 18. Dumping from a lower version client to a higher version server is not supported.
- Cause
- GitHub Actions uses the pg_dump version pre-installed on the runner, which does not automatically match the target database version.
- Fix
- Install pg_dump 18 via the PGDG repository and add the binary to the front of PATH with echo 'path' >> $GITHUB_PATH. The correct version is found first, without conflicting with the pre-installed one.
Neon pooler incompatible with pg_dump
- Symptom
- pg_dump failed with a protocol error when using Neon's default endpoint (with PgBouncer). The connection was established, but the dump never completed.
- Cause
- PgBouncer (connection pooler) does not support the communication protocol pg_dump uses to perform dumps. pg_dump requires a direct connection to the database.
- Fix
- Use Neon's direct endpoint (without pooler) in the workflow environment variable. Neon provides both endpoints — the pooled one for the application and the direct one for administrative operations.
Multi-line secret rejected by GitHub
- Symptom
- The rclone configuration string for Google Drive spans multiple lines. When trying to add it via the GitHub interface, the secret was corrupted or rejected.
- Fix
- Register via CLI: echo 'multi-line content' | gh secret set RCLONE_CONFIG. The pipe preserves line breaks that the GitHub interface does not accept directly.
Rotation deleting backup before confirming upload
- Symptom
- In the first version of the workflow, rotation of old backups ran before validating that the new file had been successfully uploaded to Drive. A silent upload failure would delete the history without a valid new backup.
- Fix
- Reorder the steps: upload → validate the file on Drive (size and existence) → only then rotate the oldest files. A failure at any validation step stops the workflow before deleting anything.
Daily cash closing reduced from ~1 hour to ~10 minutes. In the first week of the orders module: +115 orders and R$4.6K recorded by the system.
~296 automated tests running against real PostgreSQL on every push, with an 80% coverage gate. Automated daily backup validated in production since 17/07/2026, with rotation of 15 files on Google Drive. No deployment with a red CI since the pipeline was implemented.
Having tests without a deployment gate means having a safety net with holes. CI/CD is not added complexity — it is what turns tests into a real guarantee. And a backup without upload validation is not a backup: it is a ritual that gives a sense of security without the security itself.