Post

One Pipeline to Guard Them All: A Practical CircleCI Guide for Rails, React, and Python Teams

One Pipeline to Guard Them All: A Practical CircleCI Guide for Rails, React, and Python Teams

Introduction

At some point in a growing engineering team, the question is no longer whether you need CI/CD. The real question becomes:

How do we build one reliable pipeline that respects the reality of our stack?

For many of us, that stack is not a clean single-language demo project. It is a real application:

  • a Ruby on Rails backend
  • a ReactJS frontend
  • some Python modules, scripts, or services
  • Minitest and RSpec for Rails
  • Jest for React
  • pytest and unittest for Python

This is exactly where CircleCI becomes valuable.

CircleCI is a popular CI/CD platform because it gives teams a clean way to describe build, test, and deployment automation in one place: .circleci/config.yml. That file becomes the operational contract of your repository. It defines:

  • what environments your code runs in
  • which jobs exist
  • what each job does
  • what order jobs run in
  • which jobs can run in parallel
  • which jobs must succeed before deployment
  • which branches trigger which workflows

So this post is not just “what is CircleCI?”

This is a high-level engineering guide and a practical Rails-focused implementation guide for developers who want a serious CircleCI setup for a mixed Rails, React, and Python codebase.

CircleCI full-stack pipeline diagram

What CircleCI Really Solves

At a high level, CircleCI solves a coordination problem.

Without CI/CD, every developer carries local assumptions:

  • “It works on my machine.”
  • “RSpec passed for me.”
  • “I forgot to run Jest.”
  • “The Python helper script is unrelated.”
  • “We can deploy now and test later.”

In a serious application, these assumptions become expensive.

CI/CD gives you an automated system that says:

  • every commit must be built in a clean environment
  • every important test suite must run consistently
  • failures must be visible early
  • releases should be gated by evidence, not optimism

That is why CircleCI is not just a tool for automation. It is a tool for engineering discipline.

Why .circleci/config.yml Matters So Much

If CircleCI is the engine, .circleci/config.yml is the blueprint.

This file is required because CircleCI needs an explicit description of how your project should behave. In that YAML document, we usually define:

  • version of the configuration syntax
  • executors or Docker images used for jobs
  • commands we want to reuse
  • jobs such as install, test, build, and deploy
  • workflows that orchestrate job order
  • caches, artifacts, and workspaces
  • branch filters
  • optional approval gates

For a Rails developer, this matters because your pipeline usually has more than one responsibility:

  • install Ruby gems
  • prepare the database
  • run minitest
  • run rspec
  • install JavaScript packages
  • run jest
  • install Python dependencies
  • run pytest
  • run unittest
  • maybe precompile assets
  • maybe build Docker images
  • maybe deploy only from main

If that logic is incomplete or vague, your CI becomes unreliable. A good config.yml should be boring in the best possible sense: explicit, predictable, repeatable.

Core CircleCI Concepts Every Rails Developer Should Know

Before writing the configuration, it helps to understand the building blocks.

1. Executors

An executor defines where a job runs.

In CircleCI, this is often:

  • a Docker executor
  • a machine executor
  • or a self-hosted runner

For most web applications, the Docker executor is enough. It gives you a clean, repeatable environment for Ruby, Node, and Python jobs.

2. Jobs

A job is a unit of work.

Examples:

  • install dependencies
  • run Rails Minitest
  • run RSpec
  • run React Jest tests
  • run Python tests
  • precompile assets

Each job has its own steps and environment.

3. Workflows

A workflow orchestrates jobs.

This is where you define whether jobs:

  • run in parallel
  • depend on each other
  • require manual approval
  • only run on certain branches

For example, a workflow can say:

  • first prepare dependencies
  • then run Minitest, RSpec, Jest, pytest, and unittest in parallel
  • then build release artifacts
  • then deploy from main

4. Caching

CI can become slow if every run reinstalls everything from scratch.

Caching helps you reuse:

  • Bundler gems
  • Node modules or package-manager caches
  • Python package caches

Good caching does not remove the need for correctness, but it makes correctness faster.

5. Workspaces and Artifacts

Workspaces let jobs share files with downstream jobs.

Artifacts let CircleCI keep useful output such as:

  • test results
  • coverage reports
  • screenshots
  • build archives

In large teams, artifacts are underrated. They reduce guessing after failures.

What a Senior-Grade Pipeline Should Do

If I were setting up CircleCI for a large Rails application with React and Python modules, I would expect the pipeline to cover at least these concerns:

Source control hygiene

  • run automatically on pull requests and key branches
  • make failures visible before merge

Backend confidence

  • install gems
  • boot required services like PostgreSQL and Redis
  • prepare the database
  • run Rails Minitest
  • run Rails RSpec

Frontend confidence

  • install Node dependencies
  • run Jest
  • optionally run frontend build checks

Python confidence

  • create a virtual environment or use project-local installs
  • install Python dependencies
  • run pytest
  • run unittest

Release discipline

  • only deploy if all required jobs pass
  • gate production deploys behind the main branch
  • optionally require manual approval for production

That is the mindset behind the configuration below.

A Practical .circleci/config.yml for Rails, React, and Python

The following example is intentionally broad and production-minded. You will still tailor paths, dependency files, and deployment commands for your application, but this gives you a strong starting point.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
version: 2.1

executors:
  ruby_executor:
    docker:
      - image: cimg/ruby:3.3-node
        environment:
          RAILS_ENV: test
          BUNDLE_PATH: vendor/bundle
          BUNDLE_JOBS: 4
          BUNDLE_RETRY: 3
          DATABASE_URL: postgresql://circleci@127.0.0.1:5432/app_test
          REDIS_URL: redis://127.0.0.1:6379/1
      - image: cimg/postgres:16.2
        environment:
          POSTGRES_USER: circleci
          POSTGRES_DB: app_test
          POSTGRES_HOST_AUTH_METHOD: trust
      - image: cimg/redis:7.2
    working_directory: ~/project

  node_executor:
    docker:
      - image: cimg/node:20.11
    working_directory: ~/project

  python_executor:
    docker:
      - image: cimg/python:3.12
    working_directory: ~/project

commands:
  restore_ruby_cache:
    steps:
      - restore_cache:
          keys:
            - bundle-v1-
            - bundle-v1-

  save_ruby_cache:
    steps:
      - save_cache:
          key: bundle-v1-
          paths:
            - vendor/bundle

  restore_node_cache:
    steps:
      - restore_cache:
          keys:
            - node-v1-
            - node-v1-

  save_node_cache:
    steps:
      - save_cache:
          key: node-v1-
          paths:
            - ~/.npm
            - node_modules

  restore_python_cache:
    steps:
      - restore_cache:
          keys:
            - pip-v1-
            - pip-v1-

  save_python_cache:
    steps:
      - save_cache:
          key: pip-v1-
          paths:
            - ~/.cache/pip
            - venv

jobs:
  setup_rails:
    executor: ruby_executor
    steps:
      - checkout
      - restore_ruby_cache
      - run:
          name: Install system libraries if needed
          command: |
            sudo apt-get update
            sudo apt-get install -y postgresql-client
      - run:
          name: Install Ruby gems
          command: bundle install
      - save_ruby_cache
      - run:
          name: Wait for PostgreSQL
          command: dockerize -wait tcp://127.0.0.1:5432 -timeout 1m
      - run:
          name: Prepare Rails database
          command: |
            bundle exec rails db:create db:schema:load --trace
      - persist_to_workspace:
          root: .
          paths:
            - .

  rails_minitest:
    executor: ruby_executor
    parallelism: 2
    steps:
      - attach_workspace:
          at: ~/project
      - restore_ruby_cache
      - run:
          name: Run Rails Minitest
          command: |
            mkdir -p test-results/minitest
            bundle exec ruby -Itest test
      - store_test_results:
          path: test-results
      - store_artifacts:
          path: log

  rails_rspec:
    executor: ruby_executor
    parallelism: 2
    steps:
      - attach_workspace:
          at: ~/project
      - restore_ruby_cache
      - run:
          name: Run RSpec
          command: |
            mkdir -p test-results/rspec
            bundle exec rspec
      - store_test_results:
          path: test-results
      - store_artifacts:
          path: log

  react_jest:
    executor: node_executor
    steps:
      - checkout
      - restore_node_cache
      - run:
          name: Install frontend dependencies
          command: |
            if [ -f yarn.lock ]; then
              yarn install --frozen-lockfile
            else
              npm ci
            fi
      - save_node_cache
      - run:
          name: Run Jest
          command: |
            mkdir -p test-results/jest
            if [ -f yarn.lock ]; then
              yarn test --ci --watchAll=false
            else
              npm test -- --ci --watchAll=false
            fi
      - store_test_results:
          path: test-results

  python_pytest:
    executor: python_executor
    steps:
      - checkout
      - restore_python_cache
      - run:
          name: Create virtual environment
          command: |
            python -m venv venv
            . venv/bin/activate
            pip install --upgrade pip
            if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
            if [ -f requirements-dev.txt ]; then pip install -r requirements-dev.txt; fi
      - save_python_cache
      - run:
          name: Run pytest
          command: |
            . venv/bin/activate
            mkdir -p test-results/pytest
            pytest
      - store_test_results:
          path: test-results

  python_unittest:
    executor: python_executor
    steps:
      - checkout
      - restore_python_cache
      - run:
          name: Create virtual environment
          command: |
            python -m venv venv
            . venv/bin/activate
            pip install --upgrade pip
            if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
            if [ -f requirements-dev.txt ]; then pip install -r requirements-dev.txt; fi
      - save_python_cache
      - run:
          name: Run unittest discovery
          command: |
            . venv/bin/activate
            mkdir -p test-results/unittest
            python -m unittest discover -s tests -p "test_*.py"
      - store_test_results:
          path: test-results

  build_release:
    executor: ruby_executor
    steps:
      - attach_workspace:
          at: ~/project
      - restore_ruby_cache
      - run:
          name: Precompile Rails assets
          command: |
            bundle exec rails assets:precompile
      - store_artifacts:
          path: public/assets

  hold_production:
    type: approval

  deploy_production:
    executor: ruby_executor
    steps:
      - attach_workspace:
          at: ~/project
      - run:
          name: Deploy application
          command: |
            echo "Replace this step with Capistrano, Kamal, Heroku, Render, or Kubernetes deployment commands."

workflows:
  version: 2
  ci_pipeline:
    jobs:
      - setup_rails
      - rails_minitest:
          requires:
            - setup_rails
      - rails_rspec:
          requires:
            - setup_rails
      - react_jest
      - python_pytest
      - python_unittest
      - build_release:
          requires:
            - rails_minitest
            - rails_rspec
            - react_jest
            - python_pytest
            - python_unittest
          filters:
            branches:
              only:
                - main
                - staging
      - hold_production:
          requires:
            - build_release
          filters:
            branches:
              only: main
      - deploy_production:
          requires:
            - hold_production
          filters:
            branches:
              only: main

How to Read This Configuration Like an Engineer

Let us break down what this file is doing.

version: 2.1

This tells CircleCI to use the modern configuration syntax.

executors

We define three execution environments:

  • ruby_executor for Rails jobs
  • node_executor for React jobs
  • python_executor for Python jobs

This is a practical choice for mixed stacks because each language ecosystem gets a clean environment instead of forcing everything into one oversized container.

commands

The reusable commands centralize cache logic.

That matters because CI files become messy very quickly when dependency installation is repeated in every job. Reusable commands keep the file maintainable.

setup_rails

This job does the heavy backend preparation:

  • checks out the code
  • installs Ruby gems
  • waits for PostgreSQL
  • prepares the test database
  • persists the project into the workspace

For Rails applications, separating setup from test execution is often a good move because multiple downstream jobs can depend on the same prepared codebase.

rails_minitest and rails_rspec

Many Rails codebases evolve over time. Some teams start with Minitest, later adopt RSpec, and end up with both.

That is not unusual.

Instead of pretending only one test framework exists, the configuration treats both as first-class CI jobs. This is honest engineering. The pipeline should reflect the actual repository, not the idealized one.

react_jest

This job installs JavaScript dependencies and runs Jest in CI mode.

The logic supports both:

  • yarn
  • npm

That small flexibility matters in long-lived applications, especially when frontend package management has changed over time.

python_pytest and python_unittest

The Python section deliberately runs both frameworks.

Again, this mirrors the real world. Python code in a Rails organization may include:

  • background data jobs
  • ETL scripts
  • analytics modules
  • ML utilities
  • internal tooling

Some of those were likely written with unittest, and newer ones may use pytest. Your pipeline should cover both instead of forcing a rewrite before gaining CI coverage.

build_release

This job runs only after all major test suites pass.

That is an important pattern. Build steps should usually be downstream of validation steps, especially if they produce deployable artifacts.

hold_production and deploy_production

This introduces a manual approval gate before production deployment.

That is often a healthy practice for bigger systems. Automation is powerful, but production is where business risk becomes real. A manual approval step creates a small but meaningful checkpoint.

Practical Adjustments You Will Likely Make

No sample config should be copied blindly. Here are the most common adjustments you will make in a real Rails application.

Database preparation

If your app uses schema loading, db:schema:load is fine. If it depends on migrations, seeds, or multiple databases, you may need:

1
bundle exec rails db:create db:migrate

or even:

1
bundle exec rails db:prepare

Frontend path layout

If your React app lives in a subdirectory such as frontend/, then your Jest job should cd frontend before installing packages and running tests.

Python path layout

If the Python modules live in something like services/python_tools/, adjust the working directory or test commands accordingly.

Parallelism

I set parallelism: 2 for the Rails test jobs as a reasonable placeholder.

In a larger project, you may increase this and split test files intelligently so CI stays fast as the suite grows.

Deployment commands

The deployment step is intentionally a placeholder because every team deploys differently:

  • Capistrano
  • Kamal
  • Heroku
  • Render
  • ECS
  • Kubernetes

The important architectural point is that deploy should be downstream of trusted validation.

What Else I Would Add in a Mature Team

Once the basic pipeline is stable, I would usually expand it with a few more safeguards.

Linting

  • rubocop
  • eslint
  • prettier --check
  • flake8 or ruff

Security scanning

  • bundle-audit
  • brakeman
  • npm audit or a safer curated equivalent
  • Python dependency scanning

Coverage reporting

Coverage is not everything, but it helps teams detect when test discipline is drifting.

Scheduled workflows

Some teams also use scheduled workflows for heavier test suites, nightly builds, or dependency checks.

Self-hosted runners

If your workload requires private networking, special hardware, or custom build environments, CircleCI runners can become useful beyond standard cloud executors.

Common Mistakes in CircleCI Setups

The most common failure is not syntax. It is design.

Mistake 1: One giant job

If everything runs in one huge job, failures are harder to isolate and pipelines become slower than necessary.

Mistake 2: No caching strategy

A slow CI pipeline eventually becomes an ignored CI pipeline.

Mistake 3: Only testing the “main” framework

If your repo contains RSpec, Minitest, Jest, pytest, and unittest, then only testing one or two of them creates false confidence.

Mistake 4: Deployment not gated by test success

This is how teams accidentally automate risk instead of quality.

Mistake 5: CI configuration not evolving with the codebase

Your config.yml is not a one-time setup file. It is part of the application’s operational code and should evolve with the system.

Conclusion

CircleCI becomes truly valuable when we stop treating it as a checkbox and start treating it as part of software architecture.

For a Rails developer working in a modern codebase, .circleci/config.yml should not just “run something.” It should express the real shape of the system:

  • Rails backend responsibilities
  • React frontend responsibilities
  • Python module responsibilities
  • test frameworks across all layers
  • release gates that protect production

That is why a strong CircleCI configuration feels less like a script and more like an engineering agreement. It says: this is what must be true before we trust our software.

And in a large application, that kind of clarity is not optional. It is one of the things that keeps the team moving safely.

Suggested Reading

This post is licensed under CC BY 4.0 by the author.