Moving Care Friends from Azure to Northflank

Moving Care Friends from Azure to Northflank

It’s Not You, Azure... Okay, It Was a Bit You

I have a confession to make...

The Care Friends portal has lived on Azure for years. It’s a Laravel app backed by MySQL, Redis and Horizon, running on App Service and a MySQL Flexible Server, and for a long time it did the job just fine. But over the last couple of years a few things had started to bother me.

UK staging had crept up to roughly 10x its original budget. Production MySQL would become unavailable during Azure’s maintenance windows. And a release took 20 to 30 minutes to get through the pipeline, so deploys mostly happened in the evening while I kept one eye on Sentry 👀

So I moved the whole lot to Northflank. UK staging and production now run in London, Australian production runs in Melbourne, every environment is defined in a template in git, and deploys are rolling releases I’m happy to run in the middle of the day. I did the migration on my own, and that shaped a lot of the decisions you’ll read about below 😅

I’ll be upfront about the bits that didn’t go to plan. There was downtime during the cutover, and Redis HA is still on the to-do list. This post covers what I did, what I nearly did instead, and what I’d tell another engineer who’s wondering whether a move like this is worth it.

Why Northflank was already on my radar

I’d already used Northflank at Clock, so this wasn’t a leap into the unknown.

What I liked there was how little ceremony there was around infrastructure. You describe an environment once in a template (services, addons like MySQL and Redis, volumes and secrets) and Northflank builds it for you. If a service needs more memory, you change its compute plan. If you want scheduled database backups, you turn them on for the addon. None of that needs a separate infrastructure-as-code tool or a lot of clicking around a portal.

When you’re the only person doing a migration, having shipped on a platform before counts for a lot more than a nice-looking pricing page 😅

Cost and resilience were the obvious reasons to move, but there was another goal I cared about just as much. When the tech team grows, I want someone new to be able to understand how Care Friends is put together without me walking them through it. And I definitely don’t want them having to reverse-engineer how code gets into production.

On Azure, that knowledge was spread across App Service config, a Functions app, the Container Registry, the MySQL Flexible Server and a long deploy YAML. The path I wanted was much simpler: tag a commit, get it approved, and let Northflank release it.

Looking at the options (yes, including AWS)

I didn't go straight to Northflank. In early 2025 I wrote up a proper cost and options review, with our Azure setup as the baseline and three alternatives alongside it:

  • AWS, in the usual flavours. Bref for serverless PHP on Lambda, ECS for containers, and the ones everyone ends up evaluating (EKS and Elastic Beanstalk)
  • Cloudflare, for CDN, edge caching and Workers. Useful in front of the app, but not somewhere I'd want to run a full Laravel portal with Horizon workers and a shared file volume
  • Northflank, as a container platform with templates and built-in CD

Bref was interesting, but running Laravel on Lambda means rethinking queues, scheduled tasks, file storage and long-running Horizon workers. That's a lot of application change to sign up for in the middle of a hosting move. ECS and EKS can run pretty much anything, but you're then responsible for the cluster, the networking, the deploy tooling and the upgrades. That's a fair trade for a team with a platform engineer. It isn't a great one when the platform engineer is also the person writing the features 😅

So the question I kept coming back to wasn't "what's the cheapest option on paper?" It was what can I migrate, run and explain to the next hire on my own?

Northflank came out as the recommendation. But that didn't mean it was ready to go just yet...

To BYOC or not to BYOC...

In May 2025 I put together a concrete migration proposal. The UK side was straightforward, because it could run on Northflank's managed cloud in London. Australia was the problem. Northflank didn't have a region there yet, so the plan for AUS was Bring Your Own Cloud (BYOC): Northflank's control plane running workloads on a Kubernetes cluster we owned, either Azure AKS or AWS EKS in Sydney.

That would have worked. But it would also have meant that Australia was a Kubernetes cluster I had to look after (node pools, cluster version upgrades, networking, and the cloud bill that comes with it) while the UK ran fully managed. Two regions, two ways of running the same app, and one person to keep both of them healthy.

Seeing it drawn out on a diagram is what really made it click for me. I could already picture myself debugging a node pool late at night just because it was the Australian one 🙈

Then Northflank launched a Melbourne region 💪

The December 2025 proposal dropped BYOC completely. UK and AUS are both on Northflank's managed cloud, built from the same kind of template, deployed the same way. If something behaves differently in one region, it's down to config, not because the two regions run on different platforms.

Sometimes sitting on a plan for a few months has its benefits 😆

Fixing the app before moving it 🔥

Before I touched any hosting, there were two problems in the app itself that needed sorting. Moving them to a new platform would just have meant debugging them somewhere less familiar.

MySQL at 100% CPU

The database would regularly hit 100% CPU, and not on any predictable schedule. When it did, it wasn't just the notifications page that slowed down. Logins, the dashboard and other critical paths all suffered, because they were all waiting on the same MySQL instance.

The cause was the notifications management screen. From a user's point of view it's a fairly simple page, but behind it were N+1 queries, heavy queries against the notification history, and an export that loaded the entire dataset rather than chunking through it. If you've read Are You Accidentally Killing Your Database? you'll recognise the pattern.

I rewrote the worst queries, disabled the export path until it could be done properly, and CPU went back to normal levels. No amount of extra vCPU on a new platform would have fixed that.

Optimising notifications broke Redis 🙈

Of course it couldn't be that simple.

My first attempt at improving scheduled notifications was a fan-out pipeline. A scheduled command dispatched a job per customer, and each of those dispatched a job per user. It caused a slight issue. Redis ran out of memory.

You know that moment when you see OOM command not allowed when used memory > 'maxmemory' in Sentry?

Yeah. That one.

The root cause was that one Redis instance was doing three jobs: cache, queue and Horizon. The fan-out produced jobs faster than the workers could process them, so the queue lists kept growing. Azure Cache for Redis was using the volatile-lru eviction policy, and that policy only evicts keys that have a TTL set. Queue lists don't have a TTL, and neither does a lot of Horizon's metadata. So once memory hit maxmemory, Redis had almost nothing it was allowed to evict, and writes started failing.

The obvious fix is to buy a bigger Redis. But that only hides the fact that the app is producing far more data than it needs to. So instead I:

  • Produced fewer jobs in the hot path, batching users rather than dispatching one job each
  • Tightened Horizon's trim settings in config/horizon.php, so completed and recent job metadata expires sooner
  • Switched to a few coarse tags instead of tagging every job with a user ID, since Horizon stores data per tag
  • Stopped dispatching another job from inside an already-busy job when doing the work synchronously was fine

This paid off later too. Because the app had stopped flooding Redis, I could keep the Redis addon on a small plan on Northflank, and even step it down further when I right-sized after go-live.

Why Northflank won

By the time the no-BYOC proposal was ready, this is what it looked like against the goals I'd set:

Goal

What Northflank gave me

Cost

About 36% lower than our Nov 2025 Azure bill at go-live

Australia

Melbourne on the managed cloud, no BYOC

Resilience

MySQL InnoDB Cluster HA on production (3 replicas)

Operations

Templates in git under infra/, so environments are code

Path to production

Tag, approve, run a Northflank release workflow

Environments

Resources grouped into release stages (staging and production)

Deploy speed

20 to 30 minutes down to just under 5

Deploy culture

Rolling deploys instead of waiting until Sunday night

Staging is where the difference was most obvious. UK staging on Azure had grown to roughly 10x its original budget (😬). Northflank staging still costs more than that original figure, but it's about 74% cheaper than what we'd drifted into on Azure.

Across all three environments, Northflank came in about 36% below the Nov 2025 Azure total at go-live. After right-sizing in September, another 9% came off that, which puts us about 42% below Azure overall.

Mapping Azure to Northflank

Here's how each piece of the old setup maps across:

Azure

Northflank

App Service (web)

Deployment service (APP_TYPE=web)

Functions / worker host

Deployment service (APP_TYPE=worker running Horizon)

Merge webhook Azure Function

A Laravel route on the web service

MySQL Flexible Server

MySQL addon (HA on production)

Azure Cache for Redis

Redis addon (single replica, more on that below)

Azure Files share

Shared volume (ReadWriteMany)

Container Registry

Northflank build service

Front Door

Northflank load balancer with automatic TLS

The web and worker services run the same image, and APP_TYPE decides whether the container starts PHP-FPM for web traffic or runs Horizon. One build, two services.

The Merge webhook used to live in its own Azure Function, originally because handling it synchronously inside Laravel was too slow. By the time of the migration the noisy parts of the app (notifications especially) had been sorted, so I moved it into a normal Laravel route. That's one less Function app to deploy, monitor and keep in sync.

Risks I accepted (on purpose) ⏳

I tried to be honest with myself about the trade-offs before starting:

  • DNS and cutover. Lower the TTLs in Cloudflare ahead of time, plan the record changes, and keep a rollback path
  • Data integrity. Backups, checksums, and dry runs on staging before touching production
  • Performance surprises. Fix the known problems in the app first, and load-test what I could
  • Redis HA. I had connectivity issues with the HA Redis setup during the migration, so we went live on a single Redis replica. It's monitored and has been stable, but it isn't where I want it long term
  • Downtime for the data move. More on that below

One thing that caught me out more than once: on Northflank, options like MySQL HA and Redis Sentinel are set when the addon is created. You can't switch them on later, so it's worth deciding up front.

Choosing planned downtime

The earlier proposals described a nicer approach. Replicate from Azure MySQL into Northflank, check the data, promote the replica, and keep downtime to a minimum.

What I actually did was much more traditional: maintenance mode, backup, restore, storage sync, DNS switch.

The replica approach means setting up replication across two providers, monitoring replication lag, and then switching over at exactly the right moment. All while syncing a file volume full of real customer uploads, for two regions, on my own. That's a lot of moving parts. With backup and restore I knew how long each step took from the staging dry runs, I could write the whole thing down as a checklist, and if anything went wrong I could point DNS back at Azure.

It wasn't zero-downtime, and I'm not going to pretend otherwise. But for a cutover with one person running it, I'd make the same call again.

The cutover runbook

Both regions followed the same checklist. The only real differences between UK and AUS were the domain names and which Azure storage account the files came from.

Prep

  1. Deploy the intended production release to the Northflank build service before cutover night
  2. Dry-run every script against staging
  3. Apply the GitOps template (infra/northflank-*.json): web, worker, MySQL, Redis, shared volume and secrets
  4. Optionally scale web down and the worker to zero while testing the restore, so nothing writes to the new database
  5. Check GET /up returns a 200 on the Northflank default domain

Moving the data

  1. Do a full sync of the Azure file share with azcopy into a local mirror, then pack it up
  2. Dump MySQL from Azure and compress it with zstd:
mysqldump --single-transaction --routines --triggers --events \
  --set-gtid-purged=OFF ... | zstd > portal.sql.zst
  1. Restore into the Northflank MySQL addon
  2. Upload and extract the storage archive onto the shared volume
  3. Check the Passport OAuth keys made it onto the volume. They're easy to forget, and nobody can log in without them

--single-transaction gives a consistent snapshot of InnoDB tables without locking them, and --set-gtid-purged=OFF stops the dump from carrying Azure's GTID state into the new cluster.

Cutover

  1. Put the environment into maintenance mode on Azure
  2. Run a final delta sync of storage and upload the changes
  3. Stop the Azure App Service and MySQL server so nothing can write to the old database
  4. Create the custom domains on Northflank
  5. Point Cloudflare at Northflank, check TLS, and purge the cache
  6. Update the template to scale web back up (with autoscaling), attach the domains, and redeploy web and worker

Verify before celebrating

  1. Log in to the portal
  2. Dispatch a test notification job with sync turned off and check Horizon picks it up
  3. Check worker health, the scheduler, a microsite subdomain, Sentry, files on the shared volume and Redis
  4. Lift maintenance mode
  5. Watch the metrics and Sentry for a day or two before even thinking about turning Azure off

Rollback

Point Cloudflare back at Azure, start App Service and MySQL again, scale Northflank down and purge the cache. We didn't need it, but having it written down meant I slept a lot better the night before 😴

What it looks like now

There are three environments across two Northflank projects:

  • UK staging and UK production in eu-west-portal (London)
  • AUS production in aus-southeast-portal (Melbourne)

Each environment has a web service, a worker service, MySQL, Redis and a shared volume. Production web autoscales, production MySQL runs as an HA cluster, and Redis is the single replica I mentioned earlier.

What it looks like now

The Northflank feature I've grown to like most is release stages. UK staging and UK production live in the same project as separate stages, and AUS is its own project. Everything an environment needs (web, worker, MySQL, Redis, volume, secrets and the release workflow) is attached to its stage.

So if someone asks "what is production?", the answer is one stage in the UI and one template in the repo. On Azure the answer involved App Service, a Functions app, ACR, a Flexible Server, and a few resource groups I don't remember creating 🤔

Having the templates in the repo is probably my favourite part. Changing an environment is a pull request, not a session of clicking around a dashboard. The next person to join the team can read exactly what production is made of.

How code reaches production

This was one of the main design goals, not something I sorted out at the end.

  1. Push. Azure Pipelines runs lint, PHPStan and the tests
  2. Tag a release for production
  3. Approve it through the Azure DevOps environment gate
  4. Azure Pipelines triggers a Northflank release workflow and waits for it to finish
  5. Northflank builds the image and rolls it out to web and worker

Staging can deploy automatically from a successful build. Production always waits for a person to approve it.

Release stages and release workflows

On Azure, all the deploy logic lived in the pipeline. Build a Docker image, push it to ACR, tell App Service to pull it, then hope the startup script behaved. It worked, but it took 20 to 30 minutes end to end, and the pipeline was doing a lot of work that really belonged to the hosting platform.

On Northflank, the release workflow owns the deploy. In the UK production template it looks roughly like this:

  1. Create (or sync) a Release Stage called "Production UK"
  2. Attach the services, addons, volume and secrets to that stage
  3. Define a Production Release workflow that:
    • builds from the git SHA (with reuseExistingBuilds: true so it skips the build if that SHA has already been built)
    • releases that build to the web service
    • releases the same build to the worker service
  4. Expose the workflow through a webhook trigger

Production has autorun: false, so nothing reaches production without someone deliberately triggering it. Staging has autorun switched on.

Azure Pipelines just triggers the release

Azure DevOps still does the parts it's good at for us: CI, approvals, and a small deploy job. That deploy job calls Northflank, then polls until the workflow finishes.

The job template passes parameters into a shared step:

# pipelines/jobs/deploy-northflank.yml (shape of it)
parameters:
  - name: environment
    type: string
  - name: northflankWorkflowId
    type: string
  - name: northflankProjectId
    type: string
  - name: northflankReleaseWebhookUrl
    type: string

jobs:
  - deployment: deploy_app_to_${{ parameters.environment }}_northflank
    displayName: Deploy App to ${{ parameters.environment }} (Northflank)
    environment: ${{ parameters.environment }}   # ADO approval gate lives here
    strategy:
      runOnce:
        deploy:
          steps:
            - template: ../steps/trigger-northflank-release.yml
              parameters:
                northflankWorkflowId: ${{ parameters.northflankWorkflowId }}
                northflankProjectId: ${{ parameters.northflankProjectId }}
                northflankReleaseWebhookUrl: ${{ parameters.northflankReleaseWebhookUrl }}

The step is parameterised too, so the same YAML works for every environment. The API token, webhook URL and project and workflow IDs all come from Azure DevOps, so the repo only contains the shape of the call:

# pipelines/steps/trigger-northflank-release.yml (abbreviated)
parameters:
  - name: northflankWorkflowId
    type: string
  - name: northflankProjectId
    type: string
  - name: northflankReleaseWebhookUrl
    type: string
  - name: northflankPollIntervalSeconds
    type: number
    default: 30
  - name: northflankTimeoutMinutes
    type: number
    default: 90

steps:
  - task: Bash@3
    displayName: Trigger and wait for Northflank workflow
    env:
      WEBHOOK_URL: ${{ parameters.northflankReleaseWebhookUrl }}
      PROJECT_ID: ${{ parameters.northflankProjectId }}
      WORKFLOW_ID: ${{ parameters.northflankWorkflowId }}
      API_TOKEN: $(NORTHFLANK_API_TOKEN)
      SOURCE_BRANCH: $(Build.SourceBranch)
      SHA: $(Build.SourceVersion)
      BUILD_NUMBER: $(Build.BuildNumber)
      POLL_INTERVAL_SECONDS: ${{ parameters.northflankPollIntervalSeconds }}
      TIMEOUT_MINUTES: ${{ parameters.northflankTimeoutMinutes }}
    inputs:
      targetType: inline
      script: |
        # 1) POST branch + SHA to the Northflank release webhook
        # 2) name the run ado-<build-number>
        # 3) poll GET .../workflows/$WORKFLOW_ID/runs/$RUN_ID until concluded
        # 4) fail the pipeline if status != success

That's all there is to it. Northflank builds the image in the same place it runs it, and Azure DevOps waits long enough to fail the pipeline if the release fails. Naming each run ado-<build-number> also means you can match a Northflank run back to the pipeline that started it.

From 20 to 30 minutes to just under 5 ⚡

This one still makes me grin.

The old path was: Azure Pipelines builds the Docker image, pushes it to ACR, App Service pulls it and restarts, Laravel runs its startup steps, and you watch the logs. 20 to 30 minutes was normal.

The new path: CI has already proved the commit, so the Northflank workflow builds the image (or reuses an existing build) and does a rolling deploy of web and worker. Just under 5 minutes is normal now.

Shipping during the day 🚀

This is the change I'm happiest about, even more than the cost savings.

On Azure there was a real reluctance to deploy during the day. If something went wrong it could take out a chunk of the working day, so releases drifted into evenings and weekends.

With rolling deploys, new containers start up and only receive traffic once they're healthy, and then the old ones are drained. Combined with a release that takes around five minutes, I can now ship several times a day without users noticing. I've deployed while someone was demoing the platform, and nobody noticed.

To be clear, that's about day-to-day deploys after go-live. The migration itself had a maintenance window, as I covered above 🙏

Right-sizing a few months later

The sizing at go-live was mostly an educated guess. After a few months of real metrics, I trimmed the things that clearly had room to spare:

  • UK web kept the same vCPU but dropped some memory. Bot traffic still pushes CPU up even during quiet periods, so CPU stayed where it was
  • AUS web and worker both moved to smaller plans
  • Redis stepped down a plan, which was only safe because of the fan-out and Horizon work earlier
  • MySQL HA stayed exactly as it was. It's still the biggest part of the bill, and for good reason

That took about another 9% off the June Northflank baseline.

Because the environments are templates, each of those changes was a pull request. I can see what changed and when, and roll it back if the graphs say I went too far. The plan is to keep doing this: watch the metrics, trim what isn't needed, and repeat.

Let's wrap this up

I didn't leave Azure because Azure is bad. For this portal, with one engineer looking after it, Northflank was simply a better fit. It's cheaper even with HA MySQL included, every environment is a template in the repo, Australia didn't need its own Kubernetes cluster, and I'm happy to deploy while people are using the product.

It wasn't perfect. Redis HA is still on the list, the cutover had downtime, and staging still costs more than the budget it started with years ago (although it's a fraction of what it had become on Azure).

If you're thinking about a similar move, here's what I'd pass on:

  1. Fix the problems in your app first. A new platform won't fix MySQL pinned at 100% CPU or a fan-out that fills Redis
  2. Use a platform you've already shipped on if you can. It removes a lot of unknowns when you're doing this alone
  3. Check regional availability early. Running BYOC in one region and managed in another is a very different job from fully managed everywhere
  4. Planned downtime is a valid choice. Backup and restore you've rehearsed beats a clever replication plan you haven't
  5. Design for the team you want to have. Someone new should be able to read the architecture in an afternoon, and getting to production should be tag and approve
  6. Let the platform own deployment. Our pipeline's deploy job now just triggers Northflank and waits
  7. Find out why Redis is full before buying a bigger one. Then right-size based on what the graphs actually show
  8. Measure more than cost. Being able to deploy during the day changed how we work more than any of the savings did

Would I do it again? Yes, including the boring bits. Especially fixing notifications before I touched DNS.

I hope the messy middle of this has been more useful than a neat success story. And I look forward to hearing how your own migrations went 👋