Building a Remix app locally with Docker

An illustration of a bird drawing a blueprint of an airplane on a drafting table. In the background is a sunset with the actual airplane in the sky, with the label 'Remix' painted on the side
Image by Annie Sexton

Fly.io makes it easy to deploy containerized apps in seconds. The more comfortable you are with Docker, the smoother and more reliable your deployments can be. Let’s learn how to leverage Docker when developing your app locally!

Docker can give developers a high degree of confidence that their applications will run exactly as expected in production. However, many people still run into failed deployments or unanticipated problems because when developing locally, the aren’t using Docker. In this article, we’re going to explore how to setup your Remix local environment to use Docker in development.

In the end, we’re going to be able to run our application locally as a Docker image that will live reload as we make changes.

Create a new Remix app

Let’s start by instantiating a new Remix app with the default template:

npx create-remix@latest

As of right now, the default template for Remix uses Vite. In order the allow the Vite server to be exposed to Docker later on, we need to make a change to our dev script in our package.json by adding --host :

"scripts": {
    ...
    "dev": "remix vite:dev --host",
    ...
}

If you happen to be using a version of Remix that does not use Vite, this step isn’t necessary. With our Remix app initiated, let’s try running it locally:

npm run dev

With this running, we can visit localhost:5173 in our browser (or port 3000 if not using Vite) and see our sample app up and running:

Hooray!

Before we set up our local environment to use Docker, let’s deploy our app as-is to Fly.io. This will set us up with the foundational Dockerfile that we’ll rely on in configuring Docker Compose for local development.

Deploy to Fly.io

If this is your first time deploying to Fly.io, you’ll want to start by downloading the CLI flyctl, as this is the primary method you’ll use to interact with the platform.

Once installed, all you need to do is run two commands:

fly launch

This auto-generates a fly.toml (required config for Fly Apps) and a Dockerfile. The generated Dockerfile is specific to Remix, so no need to change a thing. πŸ˜ƒ

Next, deploy you application to Fly.io:

fly deploy

Once completed, your app should be available at https://<YOUR-APP-NAME>.fly.dev

Now that we have our app running on Fly.io we can get to work setting up our local environment. To do so, we’ll be relying on Docker Compose.

What is Docker Compose and why do I need it?

Working with Docker involves two steps:

  • Building the Docker image
  • Running the Docker image in a container

These both require separate commands, often with a slew of extra parameters to set things like exposed ports, volumes, bind mounts, environment variables, and more. These commands can get quite long and tedious to remember.

Docker Compose is a way of defining params you’d normally pass to your build and run commands into a single docker-compose.yml file. This way, all you need to do to get your app running in a Docker container is:

docker-compose up -d --build

Docker Compose is an invaluable tool for local development, as you’ll soon see. Let’s try using it with our Remix app.

Docker Compose for local development

First, we need to make a small change to our Dockerfile so our app runs as expected in development mode. Search for this line, which removes all devDependencies:

RUN npm prune --omit=dev

Replace this with the following, so the command only runs when our app is in production:

RUN if [ "$NODE_ENV" = "production" ]; then \
        npm prune --omit-dev; \
    fi

Next, create a file called docker-compose.yml at the root of your project:

version: "3.8" # this the version of Docker Compose
services:
  app:
    build:
      context: ./ 
    command: npm run dev
    environment:
      - NODE_ENV=development
    ports:
      - '5173:5173'

A number of these settings allow us to override things defined in our Dockerfile, things like environment variables and the command to start our application. In this case, we’re overriding the NODE_ENV variable to development, as well as the start script to npm run dev . We can now run our app in a container with the command:

docker-compose up -d --build

Once completed, you’ll be able to access your app at localhost:5173. However, any changes that you make to your code won’t be reflected unless you were to rebuild the image! That’s no good for local development, so let’s fix that.

Making local changes available

We’ll be using a feature of Docker called bind mounts to allow our code changes to pass through to the container. To do this, we’ll be setting the top-level volumes key in our Docker Compose settings like so:

version: "3.8" # this the version of Docker Compose
services:
  app:
    build:
      context: ./ 
    command: npm run dev
    environment:
      - NODE_ENV=development
    ports:
      - '5173:5173'
    volumes:
      - ${APP_DIR}:/app
      - /app/node_modules

This volumes key in Docker Compose configures both bind mounts and volumes.

Bind mounts follow the pattern </local/path>:</path/in/docker/image>, which tells Docker to use the files from the underlying filesystem instead of what’s bundled in the image.

You can think of bind mounts as a way to “cut a hole” in the Docker image to allow the underlying files on your computer to pass through.

In our case, ${APP_DIR}:/app allows us to make changes to our app code that will be reflected in our running container. You can set APP_DIR in a .env file, and it should be the absolute path to your application directory.

Using an anonymous volume for node_modules

By using a bind mount, we’re now letting our whole application codebase pass through to the container, and this includes node_modules , and that’s a problem, but it might not be obvious.

Some npm packages have platform-specific versions. For example, on macOS, esbuild will install the package @esbuild/darwin-arm64, but on Linux systems, @esbuild/linux-arm64 will be required. For this reason, we should not rely on the underlying filesystem for node_modules.

So, how do we tell Docker to exclude only this folder from our bind mount? Enter anonymous volumes.

Let’s look again at this section of our docker-compose.yml:

    volumes:
      # bind mount πŸ‘‡
      - ${APP_DIR}:/app
      # anonymous volume πŸ‘‡
      - /app/node_modules

While bind mounts are controlled by the host machine, volumes are controlled by Docker. In both cases, a parallel folder exists in the host filesystem, but in the case of volumes, instead of the host dictating what goes in it, Docker controls what goes in.

If we look in our Dockerfile, we’ll see that it runs npm ci --include-dev (basically npm install), and those dependencies get placed inside the directory /app/node_modules in our image . By adding - /app/node_modules as a volume, we’re letting Docker override the contents of our local node_modules folder in the image, thus avoiding the problem. In other words, we’re saying “Use the underlying host files for everything except node_modules.”

With our bind mount and volume set up, we can now run docker-compose up -d --build, and any changes to our code will be reflected in our container.

Live reload changes on non-Vite Remix apps

As stated earlier, the default templates for Remix now use Vite, which don’t require any extra setup for live reloading to kick in. However, if not using Vite, and your application normally runs on port 3000, you can add a second port entry to include 3001 like so:

    # ...
    ports:
      - '3000:3000'
      - '3001:3001'
    #...

For non-Vite Remix apps, live reloading operates through a web socket connection on port 3001. With this extra port exposed in our configuration, your changes will be available instantly in your container.

Conclusion

Leveraging Docker Compose for local development can give you the confidence that what you build locally will be reflected in production when you deploy your app. Docker gives you the most control over the environment in which your app runs, and Fly.io makes deploying containerized applications easy.