Replacing Manual SFTP Releases with a Test-Gated CI/CD Pipeline
Stack
- Docker
- GitHub Actions
- CapRover
- Node.js
- Express
- Jest
- Supertest
On this page
Key results
- Deployment process
- SFTP → CI/CD
- Isolated environments
- 3
- Production releases
- Test-gated
- Frontend + backend
- Dockerized
- Release cycle
- ~2 weeks
Problem
Better Coaching is a multi-tenant SaaS platform that football clubs and academies use to manage coaching, players and training content.
When the team inherited the platform, releases were manual. Changes were copied to the server over SFTP, and no pipeline validated or deployed them. Every release depended on the developer doing it knowing:
- which files had changed;
- which environment needed updating;
- which configuration belonged where;
- whether every required change had been transferred;
- what to check after the deployment.
The process worked, but it was fragile. A forgotten file or a configuration difference could break a release, and there was no simple rollback. Because the environments had drifted apart, a change that worked in development wasn’t guaranteed to behave the same way in production.
The goal wasn’t just faster deployments. The release process needed to be:
- repeatable;
- less dependent on any one developer’s knowledge;
- safe across environments;
- validated before reaching production;
- compatible with the ongoing modernization of the frontend and backend.
My role
I was the team lead on this work. I set the direction for how the platform should be built, tested and released, and led the team through containerizing the applications, splitting the environments, automating deployments and making integration tests a requirement for every release.
I also introduced the release rhythm the team settled into, so shipping became part of the normal engineering workflow instead of a separate manual operation.
Technical approach
Standardizing the applications with Docker
The first step was reducing the differences between environments. We containerized both the frontend and the backend with Docker, so the applications ran from a defined container configuration instead of manually configured servers.
The same build could now move through development, staging and production without depending on machine-specific setup, which gave the rest of the pipeline a reproducible foundation.
Separating development, staging and production
We split the platform into three environments. Development is where ongoing work is integrated and tested. Staging is a production-like environment for validating a release before customers see it. Production stays isolated from day-to-day development.
Branch-based deployment rules connect each branch to its environment:
- develop
- development
- staging
- staging
- main
- production
Automating deployment with GitHub Actions and CapRover
GitHub Actions runs the delivery workflow and CapRover handles application deployment:
- Pull request
- Automated checks
- Merge
- GitHub Actions
- CapRover
- Target environment
Deployments are driven by the repository and its branch state, not by someone copying files to a server.
Adding integration tests with Jest and Supertest
We added integration tests to the Node.js/Express backend with Jest and Supertest, in a dedicated structure alongside the application. Instead of testing functions in isolation, they exercise the API through realistic request flows. Coverage includes:
- routes and middleware;
- authentication and authorization;
- validation;
- controllers and services;
- database operations;
- HTTP responses and error cases.
Testing against a production-like database
The tests run against a separate staging/test database built on the production structure, never against production itself. They exercise real database interactions and behave more like the deployed application, while production data stays protected.
Making tests the release gate
The integration suite runs on every pull request, and code can’t be merged until the required checks pass. Tests stopped being something a developer might remember to run and became an enforced step. In effect, the pipeline asks one question before the other:
“Does the application still behave correctly?” comes before “Can we deploy it?”
If the answer to the first question is no, the second step never happens.
A predictable release rhythm
Changes now move through development and staging before production, on a roughly two-week cycle. Instead of coordinating an SFTP upload, release work means:
- reviewing changes;
- checking automated test results;
- validating the release candidate on staging;
- promoting the release when it’s ready.
Decisions & trade-offs
Automating deployment wasn’t enough
Early on, automating deployment looked like the biggest win. But it raised an obvious problem:
Automating an unsafe deployment process only lets you ship broken software faster.
A successful Docker build or deployment doesn’t prove the application still behaves correctly, and the platform already had complex authentication, permissions, data operations and business logic. So we treated the project as building a software-delivery pipeline, not just automating deployments, with verification required before anything could be released.
“Can be deployed” vs. “ready to be deployed”
A green deployment only tells you the software can be deployed. Passing integration tests tell you it’s ready to be deployed. Keeping those two questions separate, and enforcing both, is what made the pipeline a safety layer rather than just a faster way to release.
A staging copy instead of production
Testing against a separate database built on the production structure means maintaining a second database. In exchange, tests get realistic data behavior without any risk to customer data, which made it the clear choice.
Results
No more manual deployments
SFTP-based releases are gone. Deployments are triggered from version control and run through GitHub Actions and CapRover, the same way regardless of which developer is releasing.
Test-gated production releases
A change can no longer reach production just because the deployment itself succeeds. The integration tests have to pass first.
Controlled environment differences
Docker and separate environments removed most of the reliance on machine-specific configuration. Issues are easier to reproduce, and changes are easier to validate before users see them.
Staging as a real safety layer
Changes are validated under production-like conditions before they are promoted, so environment-specific problems surface on staging instead of after a production release.
Less dependence on individual developers
The release steps that used to live in someone’s head are now encoded in:
- Docker configuration;
- GitHub Actions workflows;
- environment rules;
- automated tests;
- CapRover deployment configuration.
That makes delivery more consistent and easier for the whole team to maintain.
A safer foundation for continuous delivery
The biggest change wasn’t swapping SFTP for GitHub Actions. It was moving from a manual procedure to a structured delivery system where every change follows the same path:
- Developed
- Reviewed
- Tested
- Validated on staging
- Deployed
That gives Better Coaching a stronger base for shipping new features with less avoidable deployment risk.