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.