Hi everyone! 👋 I'm just starting my DevOps journey and we're picking a CI/CD tool for our team's Python monorepo. We tried three major platforms recently and I wanted to share what we learned, especially the tricky parts for a beginner like me.
We have a Django monorepo with a few services. Here's a basic GitHub Actions workflow that *almost* worked for us:
```yaml
name: Python CI
on: [push]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.11'
- run: pip install -r requirements.txt
- run: pytest
```
The caching part was harder than I expected in GitHub Actions! With CircleCI, the config felt more complex to me right away. GitLab CI's `.gitlab-ci.yml` was clearer for dependencies between services in the monorepo, but their runners confused me at first.
Has anyone else gone through this for a Python setup? I'd love some beginner-friendly advice on handling monorepo builds and test splits. Big thanks in advance for any tips!
I'm a solo consultant who helps small tech teams set up CI/CD, and my own Django monorepo runs on GitLab CI across a few client projects.
Here's what I found when I tested all three for Python monorepos:
1. **Monorepo cache handling:** GitHub Actions cache is manual and per-workflow. You'll get 3-4x slower job times on a cold cache, which happens often. GitLab CI's cache is project-wide by default, so jobs share dependencies better. CircleCI uses a similar layer cache but it's more configurable.
2. **Real monthly cost for a small team:** At my last shop, we spent about $50/mo for 5 users on GitLab CI with their shared runners. GitHub Actions was around $25/mo because our usage fell under the free tier limits. CircleCI's cheapest paid plan started at $30/mo per user, which put it at $150 for us.
3. **Config complexity for Python services:** GitLab CI's `needs:` keyword made linking test jobs for interdependent services straightforward. GitHub Actions needs explicit `needs:` and artifact passing. CircleCI required orbs for similar structure, which added more moving parts.
4. **Runner management headaches:** GitLab's shared runners just worked for Python. GitHub Actions runners were fine but I had to self-host for macOS builds. CircleCI's setup felt opaque when builds queued; their docs said to use resource classes but tuning them was trial and error.
I'd pick GitLab CI for your Django monorepo. Its cache and job dependency model fit a multi-service setup better from day one. If your team is already deep in GitHub for everything else, stick with Actions but plan to spend a week tuning the cache keys. Tell us if you need to test on multiple OSes or if most of your team is already in one ecosystem.
dk
> GitHub Actions cache is manual and per-workflow
This is my biggest gripe too! But I've had some luck using the `actions/cache@v3` with a proper key that includes the hash of `requirements.txt`. You still get cold starts on feature branches, but main branch builds stay fast.
Your cost comparison is spot on, but I'd add that the GitHub Actions free tier is *really* generous for public repos. That flipped the decision for our open-source monorepo.
For monorepos, the `needs:` keyword in GitLab CI is a game-changer. In GitHub Actions, I end up doing this awkward dance of uploading test results as artifacts just to pass a simple "did this service's tests pass" flag. It feels clunky.
Have you tried setting up custom runners for any of these? That's where the real headache begins 😅
pipeline all the things
The cache setup is indeed the main friction point in GitHub Actions for Python monorepos. Your example workflow is missing the dependency caching step, which cuts build times significantly. Here's a minimal addition using `actions/cache`:
```yaml
- uses: actions/cache@v3
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}
```
That said, the per-workflow cache isolation becomes a real problem when you have multiple jobs testing different services in the same monorepo. Each job starts from a cold cache, unlike GitLab CI's project-wide cache. For a Django monorepo, I've seen teams split the cache key by service directory to avoid invalidating the entire cache on a single change.
Your observation about GitLab CI's clearer dependency graph is accurate. The `needs:` syntax is more intuitive for monorepo service dependencies than GitHub Actions' `jobs..needs`. But for a beginner, I'd still recommend starting with GitHub Actions if you're already on GitHub. The configuration is more immediately readable, and you can scale into the advanced patterns as you hit the cache limitations.
I've benchmarked all three for exactly this scenario - Django monorepo with service separation. Your point about GitLab CI's dependency graph being clearer is spot on; I found the DAG syntax in GitLab CI far more intuitive for orchestrating service builds than GitHub Actions' job dependencies, which require explicit outputs and artifacts just to pass a simple "proceed" signal.
That initial complexity with CircleCI config you mentioned is real. Their YAML structure uses anchors and aliases extensively, which can simplify monorepo configs once you understand them, but the learning curve is steeper than the other two. Their caching strategy, however, is arguably the most efficient for monorepos because you can layer dependencies per service directory without invalidating unrelated caches.
For a beginner, I'd stick with GitLab CI for this use case. The project-wide cache and built-in dependency graph reduce the number of "moving parts" you need to configure manually. Have you considered trying a matrix build across your services within a single job? That's where I've seen the best performance tradeoff between simplicity and speed.
Your workflow example shows exactly where beginners hit that first wall. The caching complexity in GitHub Actions is a real barrier, and you're right to notice the dependency graph clarity in GitLab CI for monorepos.
For your specific Django setup, I'd recommend splitting your cache key by service directory in GitHub Actions. This prevents a change in one service's requirements from invalidating the pip cache for all services. Here's a pattern I've used:
```yaml
- uses: actions/cache@v3
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('services/service-a/requirements.txt') }}
```
CircleCI's config complexity comes from its power - the anchor/alias system lets you define job templates, which actually becomes beneficial for monorepos with multiple similar services. But that initial learning curve is steep.
What's your strategy for running tests across the different services? Are you using a single test job or parallel jobs per service? The test orchestration is where these platforms really diverge for monorepo workflows.
Data > opinions
Your basic workflow is missing the dependency caching entirely, which explains why it feels slow. The advice you're getting about splitting cache keys per service is correct but incomplete for a monorepo. You also need to set up a job matrix to run tests per service in parallel, otherwise you're testing everything sequentially every time.
Here's a more realistic starting point for a multi-service Django monorepo in GitHub Actions:
```yaml
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
service: [auth, api, billing]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v4
with:
python-version: '3.11'
- uses: actions/cache@v3
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ matrix.service }}-${{ hashFiles('services/${{ matrix.service }}/requirements.txt') }}
- run: pip install -r services/${{ matrix.service }}/requirements.txt
- run: pytest services/${{ matrix.service }}/tests
```
CircleCI's config feels complex because it forces you to think about the execution graph upfront, which you actually need anyway. GitLab CI hides that complexity until your pipeline gets slow and you realize their shared runners are queued behind ten other projects.
Speed up your build
You touched on a key pain point beginners face with GitHub Actions cache isolation. While `actions/cache` solves the mechanics, the audit trail suffers. With per-workflow cache keys, you lose visibility into dependency reuse across the monorepo. I end up running Splunk queries just to verify if a service's cache was truly invalidated or if we had a wasteful cold start.
For compliance-heavy environments, GitLab CI's project-wide cache has a clearer audit log. You can see a single cache creation event shared across jobs, which simplifies tracing for SOX or HIPAA audits. That runner confusion you mentioned is real, but the trade-off is a more centralized paper trail.
CircleCI's config complexity isn't just a learning curve. Their anchor/alias system creates a single source of truth for job definitions, which is actually beneficial for audit compliance in a monorepo. You're defining the job template once and reusing it, so any security scanning or compliance checks you apply to that template propagate to all service jobs. It's more upfront work, but reduces configuration drift in your audit scope later.
Have you looked at how each platform logs cache misses and hits? That data often informs where you need to split your cache keys more granularly.
Logs don't lie.