Back to articles

Pretty fly for a node guy: Deploying the Calmly bot with fly.io

Pretty fly for a node guy header

Yes, I know, the title of this article has carbon-dated me. But alas, I couldn’t miss the punderful opportunity. Ahem, moving on.

As part of Calmly’s journey, I’ve been focused on making sure to keep things simple, especially while it’s in the early stages. Calmly, for a bit of context, is a Slack bot I’ve been building to help software engineers care for their mental health with daily-checkins and mindful reflections. I also wanted to keep things simple with deploying the backend - I wanted something that wouldn’t involve too much mental overhead but was still reliable and low-cost. That’s when I revisited fly.io, a platform I’d played around with a few years ago.

The backend of Calmly is just a small NodeJS app written in Typescript. Now, while there are many cloud options available, I deliberately steered clear of the big players like AWS. There’s a lot of flexibility there, sure, but at this point in Calmly’s journey, I don’t want to be stressing about cloud spend or managing complex configurations.

fly.io ticked all the boxes - minimal setup, low cost, and, best of all, I even had some old credits sitting unused in my account from some years back. We’ve got a winner!

Getting started with fly.io

I had vague memories of fly.io having a reasonable developer experience, but I was not entirely prepared for just how joyful it would be to get started with! My app was pretty simple, but I had fully expected some convoluted setup process where I’d be knee-deep in tutorials for a few hours before getting something half-working. Alas - I had the whole thing online in less than 10 minutes!

This is quite literally all I had to do:

  1. Install the fly.io CLI on to my Mac:

    brew install flyctl
    
  2. Authenticate the CLI with my account:

    fly auth login
    
  3. Run the launch command inside my project folder:

    fly launch
    

    This was the bit of magic joy I wasn’t expecting. The fly launch command scanned my project, recognised that it was a NodeJS app using Typescript, and generated a Dockerfile to build and run it for me automatically. It also created a new app in my fly.io account, so I didn’t even have to go into the web dashboard to set that up. After answering a few prompts (like whether I wanted to deploy right away or just generate the config), it was ready.

  4. Next, I deployed the necessary environment variables using fly secrets:

    fly secrets set MY_SECRET_HERBS_AND_SPICES=salt_and_pepper
    
  5. Finally, I deployed the app:

    fly deploy
    

From start to finish, in less than 10 minutes, Calmly’s backend was live. The simplicity of the entire process brought a giant grin to my face - I was one happy developer.

Just one small problem, it was using way too much memory

Once deployed, everything worked… almost. I kept getting emails from fly.io stating that my instance had run out of memory. I bumped the machine memory up a notch and jumped over to the app dashboard - and holy moly, my app was using more than 400MB of memory! For such a small app, that seemed excessive.

After a bit of digging around, I realised the Dockerfile that fly.io generated was unintentionally using a development build of my app, not production, and was using ts-node to run the Typescript files directly. Now this is all fine and dandy for local development but not ideal for production.

Here’s the Dockerfile that fly.io initially generated:

# syntax = docker/dockerfile:1

# Adjust NODE_VERSION as desired
ARG NODE_VERSION=20.15.1
FROM node:${NODE_VERSION}-slim AS base

LABEL fly_launch_runtime="Node.js"

# Node.js app lives here
WORKDIR /app

# Set production environment
ENV NODE_ENV="production"

# Install pnpm
ARG PNPM_VERSION=9.5.0
RUN npm install -g pnpm@$PNPM_VERSION

# Throw-away build stage to reduce size of final image
FROM base AS build

# Install packages needed to build node modules
RUN apt-get update -qq && \
    apt-get install --no-install-recommends -y build-essential node-gyp pkg-config python-is-python3

# Install node modules
COPY --link package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile

# Copy application code
COPY --link . .

# Final stage for app image
FROM base

# Copy built application
COPY --from=build /app /app

# Start the server by default, this can be overwritten at runtime
CMD [ "pnpm", "run", "start" ]

Notice that the command at the bottom is running pnpm run start, which to be fair is often wired up to a build && run command in the package.json file. In my case, the start command was just calling ts-node app.ts . Teach me for rushing my packages.json configuration!

Here’s the revised Dockerfile:

# syntax = docker/dockerfile:1

# Adjust NODE_VERSION as desired
ARG NODE_VERSION=20.15.1
FROM node:${NODE_VERSION}-slim AS base

LABEL fly_launch_runtime="Node.js"

# Node.js app lives here
WORKDIR /app

FROM base AS build

RUN apt-get update -qq && \
    apt-get install --no-install-recommends -y build-essential node-gyp pkg-config python-is-python3

# Install pnpm
ARG PNPM_VERSION=9.5.0
RUN npm install -g pnpm@$PNPM_VERSION

# Install node modules
COPY --link package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile

# Copy application code
COPY --link . .

# Build the application
RUN pnpm run build
RUN pnpm prune --prod

FROM base

COPY --from=build /app /app

ENV NODE_ENV="production"

CMD [ "node", "dist/app.js" ]

Notice that I’ve run pnpm install --frozen-lockfile without the NODE_ENV="production" environment variable being set before hand. This is so the build step has access to the Typescript compiler, tsc . After I run the build step, I then clean up the non-production npm packages by running the pnpm prune --prod command. This helps keep the Docker image size nice and small.

Now that it’s all built, I simply copy those files across from the build stage image to the base image, set the NODE_ENV="production" environment variable and call node dist/app.js as the final image run command.

These small changes got the runtime memory down to less than 100MB—a big improvement, though still a bit high for such a simple app (that’s NodeJS for you!). I’m sure I could keep tweaking things to further reduce the memory usage, but as it stands, I fit into the free tier for my fly.io account, so I’m happy to pause for now.

Flying off into the sunset

Once that memory issue was sorted, the app ran smoothly. I also discovered that fly.io had set up automated deployments for me too via a GitHub action - whenever I push to the main branch, the app redeploys automagically. And if that wasn’t enough, I’ve also got a nice web UI to monitor logs and metrics from inside the fly.io dashboard.

Since the runtime is just hosting a Docker container, I also have the flexibility to spin things up locally to replicate issues that arise in production. And in theory, I could easily switch to another hosting platform in the future without too much hassle.

For Calmly, fly.io has been a great solution. The grin-inducing developer experience is second to none, it’s affordable, and most importantly, it doesn’t get in the way. I’d highly recommend you give it a spin if you ever need a place to host your app online in the future!

By the way, if you’re curious about Calmly, it’s a tool I’m building to help software engineers take care of their mental health. With daily check-ins and mindful reflections right inside Slack, Calmly encourages healthier habits and more thoughtful, balanced workdays. Stay tuned for more updates as I continue refining it!