← All case studies GitHub → GitLab

Outschool mirrored GitHub while GitLab became the place merges landed

Outschool imported GitHub into GitLab in 2022, then mirrored merge requests back so GitHub Actions and Heroku review apps kept running. A few senior engineers finished the GitLab move and the Heroku-to-Fargate move together in under three months. Traffic shifted on a Cloudflare load balancer over a few days.

Jimmy Berry wrote Heroku Exodus and Gitlab Migration for Outschool. The page was published on 10 January 2022. Its modified time is 2 March 2023. One post covers two moves. GitHub to GitLab came first, because that was how they could leave Heroku for AWS.

This note stays on the GitLab cutover, and it keeps the Heroku facts that explain why the mirror existed. ECS Fargate, RDS, and the Cloudflare load balancer are in the same essay.

The GitHub → GitLab playbook is the import, the identities, and the history. The GitHub Actions → GitLab playbook is the workflow port they did next. Outschool’s trick was to keep GitHub alive as a mirror while that port happened.

Why the repo moved before the dynos

Heroku Scheduler was the first hurdle. The periods it offered were too rigid, and they needed several language environments from one repository. After they agreed, batch jobs went to AWS. When AWS Batch worked, every existing Scheduler job followed. That success is what made the rest of Heroku look optional.

The goals he lists are flexibility for builds and for shipping more than one app from a repo, lower cost from running the resources themselves, one place for the things that could not go on Heroku, and more control. Security tooling and CI goals were in parallel, because some of the same choices served both. The constraint on all of it was a small change to the developer loop.

They wrote down what had to be replaced so the loop would not get worse. A load balancer and app servers. Postgres. Background jobs. A source of truth for secrets. An overview of environments, with deploy, rollback, and review apps. High-level metrics. He says the list is not exhaustive. Two items were already done before the website move grew to this size. The primary Postgres database was on RDS, and scheduled work was on Batch.

The database move had its own rehearsal. Scripts were practiced until the cutover matched the rehearsal, and the final switch happened in a scheduled downtime. He does not say how long that window was. Secrets at that stage still came from Heroku config vars. A release script turned each environment’s config, including a review app’s, into an AWS secret that Batch could read.

Agreement, then an incremental cut

They prototyped the workflow on GitLab and a website deploy on ECS. ECS Fargate was the host they already knew from a few smaller services. A container plus a task count plus an ALB was the shape. After the prototypes worked, they took the direction through engineering guilds and an all-engineering design review. The direction section says general agreement took a few weeks. The conclusion says the consensus took about a month. Both lines are in the essay.

An all-at-once cutover looked impractical. They imported the GitHub project into GitLab, and from then on source changes and reviews happened on GitLab. GitLab CI mirrored those changes back to GitHub, so the old integrations and CI kept firing. The Heroku migration resumed after the GitLab move was in place.

Import, then stop reviewing on GitHub

A mirror job pushed the review branch to GitHub and opened a pull request. The script titles that pull request with a [GITLAB] prefix. The body points at the GitLab merge request. The pull request number is written to deploy.env, reported as a dotenv artifact, and cached under a key tied to the branch. The next pipeline can see that the pull request already exists without calling the API again. If GitHub answers that a pull request is already open, the script keeps the cached number. Any other error fails the job.

A GitLab environment named mirror/... uses that number as its URL. Opening “view latest app” on the mirror environment lands you on the GitHub pull request, which is where the old GitHub Actions checks still showed up.

When the merge request closes, a stop job deletes the GitHub branch. GitHub then closes the pull request. GitLab also stops an environment when the merge request merges, when it closes, or when auto_stop_in expires. That one lifecycle is what deletes the branch.

Heroku review apps were still created by the GitHub integration. A no-op GitLab job, running in a busybox image, turned the mirrored pull request number into a Heroku review URL, so a developer could open that app from the GitLab merge request too. The hostname in the essay is a sample, example-staging-pr-$GITHUB_PR.herokuapp.com. Treat it as the pattern, not a live Outschool host. The Heroku app still died when the GitHub pull request closed, which happened because the stop job had deleted the branch.

Port the workflows one merge request at a time

Once the mirror was up, they moved GitHub Actions to GitLab CI in slices. A merge request could delete an Actions workflow and add the GitLab job in the same change. From the author’s side, more of the result showed up on the GitLab merge request, and there was less reason to open GitHub.

GitLab CI covers most of what Actions did, and the shape is different enough that a straight copy was not the goal. They still tried to change little. Child pipelines took the place of an AWS CodePipeline. A pipeline here is jobs, variables, and a git hash. A child pipeline hangs off a parent and can carry a different set of jobs and variables, including a definition a parent job prints at runtime. A CDK synthesizer they already had emitted GitLab CI jobs instead, so those stacks did not have to be rewritten before the runner changed. Other jobs could depend on them without polling an API. The same trick applied one job template to many directories and ran only the ones a merge request touched.

They also built a CI image, pushed to GitLab’s registry, with the tools those jobs kept installing. Pulling the image beat installing on every run, and the runners could cache it. Deployment jobs got environment metadata, so the merge request showed which environments it would touch.

Runners, secrets, and the site

CI had already been slowing down. They had refactored it, and they decided larger machines were the cheaper next step. They ran their own runners on AWS with the npalm/gitlab-runner/aws Terraform module, for the autoscaling and because they were already on AWS. Job tags sent the heavy work to those machines. Shared GitLab runners stayed available for the rest.

Secrets moved on a different axis. GitLab CI variables can stand in for Heroku config vars, and the scope field can target review/*. They still made AWS Secrets the source of truth for runtime, because everything already ended up there. CI-only secrets stayed in GitLab. A config subcommand on their CLI writes the AWS secret and restarts the service when a restart is required. On a review branch you push, open the merge request, and run config locally against that branch. In production the command is enough, because the restart follows.

The website image started from the upstream Node image. Copy the code, install dependencies, set the environment. GitLab CI built it and pushed to ECR, where their production images already lived. A small deployable description lists the service. The sample he prints uses review.example.com, 4 vCPUs, 2048 MB, two tasks, container port 3000, and a health check at /.well-known/health-check. Review deploys use a scaling mode he spells FewerThenDesired. The other mode is MoreThenDesired. CDK outputs become the GitLab environment URL. Rollback is re-running an older deployment job from the environment history. Staging and review apps share a set of pre-prod ALBs. Production has its own, so a review app does not mean a new load balancer.

Heroku’s production dynos had 8 vCPUs. The largest Fargate task size he cites has 4. They started with twice as many tasks as they had dynos, to keep the same total vCPU and RAM. Load testing said ECS was comparable, or a bit better. Honeycomb, which moved with the app, had a dashboard comparing Heroku processes to Fargate processes. That comparison caught a setup problem during the load test. CloudWatch covered the machine metrics.

The traffic shift

Cloudflare was already proxying the Heroku app, so a Cloudflare load balancer could move traffic without a DNS cutover for users. Two origin pools. One sent everything to Heroku. The other split between AWS and Heroku. They rehearsed on staging. In production the Heroku pool had priority until they were sure, then they switched to the split and turned the dial. Over a few days all traffic went to AWS. Through the transition they deployed review apps, staging, and production to both places, so they could fall back.

After a week they deployed an empty application to Heroku. That was the check that nothing still called it. They stopped deploying there, and later removed the app.

The loop he prints is short. Push to GitLab, open a merge request, merge, promote, all without a second site. A deploy button on the merge request triggers production. They only got there after the mirror had carried Actions and Heroku through the middle weeks.

A few extras came with the CI port. GitLab’s test report flags a failure that is already on the target branch, which saved repeat arguments. Coverage shows in the diff. Any job can be retried on its own, including one cell of a matrix. Actions could not do that cell. They started using GitLab feature flags next to a custom flag system they already had.

How long, and what that number includes

Berry says a few senior engineers finished in under three months. Consensus had taken about a month before that, or a few weeks, depending on which sentence you use. Ordinary interruptions are included in the span. The span covers the GitLab move and the Heroku-to-Fargate move together.

Afterward they added production ECS tasks to tune event-loop latency. He says that extra node count would not have been cost-effective on Heroku. They also started splitting the main app into smaller services, which the Heroku build had blocked.

If GitHub Actions has to stay up on day one, import the project and mirror pull requests back until each workflow has a GitLab replacement. The import playbook covers the import limits. The Actions playbook covers the job port. Berry published the mirror scripts he used in those weeks.