Docker on MacOS will always be slower than on Linux (well, unless Apple Silicon ends up panning out, which it looks like it might!), but it doesn't have to be as infuriatingly slow as it is by default.  In fact, you can get it pretty close to native speed and it's not even all that difficult.  It turns out that developing with Docker on MacOS can actually be a really enjoyable experience, not just a compromise you make in order to have a portable development environment.

The problem is that the default file sync strategy that Docker on MacOS uses is garbage.  Sure, it's consistent and compatible, but it's just about the slowest possible option you could go with.  As your Docker app runs, it reads and changes files across that sync, which means that every file access slows down your application a little bit more.  Compounded, that means that even your average runs are bitterly slow.

Enter Mutagen.  Mutagen does a lot of things and is a very interesting technology in general, but what we're primarily interested in is how it can perform lightning-quick syncs between your local computer and a Docker volume.  How fast is it?  Well, the title of this article isn't hyperbole, in fact, it's underselling things.  My Jest test suite went from taking 73.5 seconds to run to only taking 22.9 seconds (on average).

So now that we know what the problem is and what the solution is, let's fix Docker on MacOS.

First, you have to install Mutagen, and as of the writing of this article, you need to install a beta release.  We need version 0.12.0 or higher, since that's the version that starts supporting mutagen compose (which we'll get to in a minute).  There should be fairly low risk from this, though, since Mutagen's own website says:

Beta channel releases are fairly stable and can be thought of as something akin to release candidates.

To install Mutagen and start the sync daemon, first, make sure you have Homebrew installed, and then:

$ brew install mutagen-io/mutagen/mutagen-beta
$ mutagen daemon start

Next, you'll need to modify your docker-compose.yml file to define the sync that Mutagen will spin up.  Assume your current volumes entry for a container looks like:

    volumes:
      - .:/code

You'll want to create a new section at the bottom of your file with an x-mutagen configuration.  mutagen compose will read that and build your sync from the options you set there:

x-mutagen:
  sync:
    defaults:
      ignore:
        vcs: true
      mode: "two-way-resolved"

    mount-code:
      alpha: "."
      beta: "volume://mount-code"

alpha is where you're syncing from, and beta is the sync mount point that Mutagen will create.  Also of note is the two-way-resolve sync mode we're using.  Mutagen has great documentation on how its synchronization modes work, but in short, we're using the mode that prioritizes host files.  Since this is development and the files on the host system are the canonical ones anyway, this is safe and prevents conflicts that have to be manually resolved.

Finally, we have to update our volumes entry to use the new mount point:

    volumes:
      - mount-code:/code

And that's it!  Well, that's nearly it.  docker-compose doesn't know how to read an x-mutagen entry, so instead we now run this file with the mutagen compose command, which is essentially a pass-through to docker-compose after having performed the various sync setup steps you've defined.

The first time you run this will be longer than before, as Mutagen will need to create the sync mount and perform the initial synchronization, but then it will run as a daemon and keep changes automatically in sync.  I have found that occasionally it will run wild with the CPU for a couple dozen seconds as it updates its index (or something), but it hasn't significantly affected my development, especially not when compared against the incredible speed upgrades it provides.

Want to go really fast when starting your new SaaS business? Try Nodewood, a Node.js SaaS starter kit!  Don't spend weeks or months writing your own user authentication, subscription payments system, team management, and more - just use Nodewood and get all that out of the box! Start writing your business logic today when you build with Nodewood!

Using Mutagen in Nodewood

Now that you have an idea how Mutagen works and how to set it up for your average project, I wanted to provide explicit steps for using it in your Nodewood projects.

There are two steps to using Mutagen in Nodewood: 1) Using an application-specific docker-compose.yml file, and modifying your .nodewood.js configuration file to use Mutagen.  (Of course, you'll need to make sure you've already installed Mutagen according to the instructions above.)

1) Set up an app-specific docker-compose.yml file

Nodewood will use a file in the app folder before it will use a file in the wood folder, and this applies to your Docker files as well.  Create a file in app/docker/docker-compose.yml, and paste the following:

version: '3'

services:
  postgres:
    build:
      context: .
      dockerfile: ../../wood/docker/Dockerfile.postgres
    networks:
      - app-network
    environment:
      POSTGRES_PASSWORD: nodewood
      POSTGRES_MULTIPLE_DATABASES: "nodewood,test"
    expose:
      - 5432
    ports:
      - '5432:5432'
  ui:
    build:
      context: ../..
      dockerfile: wood/docker/Dockerfile
    env_file: ../../.env
    volumes:
      - nodewood-code:/nodewood
    command: yarn dev-ui
    ports:
      - '8888:8888'
  api:
    build:
      context: ../..
      dockerfile: wood/docker/Dockerfile
    networks:
      - app-network
    volumes:
      - nodewood-code:/nodewood
    ports:
      - '3000:3000'
    links:
      - postgres
    depends_on:
      - postgres
    command: yarn dev-api
  nginx:
    build:
      context: .
      dockerfile: ../../wood/docker/Dockerfile.nginx
    networks:
      - app-network
    ports:
      - '80:80'
      - '443:443'
    links:
      - api:api

networks:
  app-network:
    driver: bridge

volumes:
  nodewood-code:

x-mutagen:
  sync:
    defaults:
      ignore:
        vcs: true
      mode: "two-way-resolved"

    nodewood-code:
      alpha: "../.."
      beta: "volume://nodewood-code"

This will set up your Mutagen sync mount, and ensure that you're continuing to use the Dockerfiles for Nginx and Postgres that live in the wood/docker folder.

2) Modify your Nodewood configuration file

In your project root, you'll find a file called .nodewood.js.  This file contains your Nodewood project information, including your keys that are used to authenticate with the Nodewood server when updating the library code.  All you need to do is add the following two lines to the exports object:

  composeCommand: 'mutagen',
  composeArgs: ['compose'],

This will instruct the Nodewood CLI to use mutagen compose instead of docker-compose when starting your project for development or tests.

NOTE: You will need to have v0.13.2 or later of the Nodewood CLI tool installed.  You can update with yarn global add @nodewood/cli.

That's it!  Some simple copy and paste, and your Nodewood development experience should be dramatically improved!