Migrating to Fly.io

Young deer working on a computer with hot air balloons in background, digital artJan 11, 2024 7 min readby Daniel Nagy

This past week I decided to migrate my website from Vercel to Fly.io. In this post I'll talk about why I migrated and what some of the challenges were with the migration.

Why I Migrated#

There were a few reasons why I decided to migrate off of Vercel but they all really boil down to one thing. Essentially, I migrated because my website is API driven and not static. Specifically, my website has user login which prevents me from using Vercel's edge cache feature. When you use Vercel's edge cache it caches the entire HTTP response, including any headers.

This is problematic for session state. Any page that may receive a Set-Cookie header is not static. Because of this I did not use Vercel's edge cache for my website. This resulted in really bad load performance. My website doesn't have active users. Throughout the day I may get a few blog post views. These views are likely spread out in time meaning that most of my users experience a cold start.

This is compounded by a cold start from my database service. I've experienced cold starts from Neon approaching 6 seconds 😬. Occasionally my serverless function would timeout and the user would get an error page. On the one hand using Neon made the migration easier since I didn't have to migrate my database but because of these cold starts it may be the next to go.

In addition, I also wanted the ability to hold state so that I can play around with things like WebSockets and Redis. This isn't to say that Vercel is a bad service. It's just that my website is not a good fit for Vercel. Vercel is designed to host static content, not dynamic content.

Why Fly.io?#

Why did I choose Fly.io? If Heroku still had a free tier I probably would have just used Heroku. However, because they got rid of their free tier I decided to search the internet for a free alternative. As I was researching different services Fly.io really stood out to me. I liked their branding and their language. Their docs were good and their API seemed intuitive and well thought out. I really got the sense that Fly.io was built by engineers for engineers. Best of all, they offer free resource allowances that they claim can run a small fullstack app, full-time, for free 👌.

Challenges With the Migration#

The migration wasn't super challenging. I purposefully tried to avoid making decisions that would lock me into Vercel. However, there were some challenges none-the-less. Here are some of the more notable challenges I experienced. This information should be useful for anyone else thinking about migrating off of Vercel.

  • Adding a web server.
  • Updating the build.
  • Moving application secrets.
  • Updating CI/CD.
  • Porting Vercel Behavior.

Adding a Web Server#

Vercel is a serverless platform. When you run an app on Vercel you don't need a web server. Instead you create serverless functions that take a request as input and return a response as output. In my case adding a web server to my app was pretty straight forward. I was already bundling my app into a single serverless function with its own primitive routing. I didn't necessary do this to make migrating off of Vercel easier. I did this to improve performance and because Vercel limits the number of serverless functions you can have on their free tier.

For the most part I just had to replace VercelRequest and VercelResponse with the request and response objects used by my web server. I use GraphQL so most of my API was exposed through a single endpoint. I just had a handful of other routes for things like OAuth and Sentry tunneling.

Updating the Build#

Vercel requires its own build step. This is done using the build command from the Vercel CLI. Fly.io is less opinionated about how you build your app. You can either use a Docker container (recommended) or a buildpack to build your app for Fly.io.

I opted to go with a Docker container since that is what Fly.io recommends. However, I was already building my app and running migrations in GitHub Actions and I wasn't convinced that I should be doing that inside the Docker container — maybe I'll change my mind in the future. Instead I decided to just copy my prebuilt application to the docker container. This makes my Dockerfile really basic. I pretty much just install Node, openssl, and copy my app's files to the docker container. If you're curious this is my Dockerfile.

# syntax = docker/dockerfile:1

FROM node:${NODE_VERSION}-slim as base

LABEL fly_launch_runtime="Node.js/Prisma"

ENV NODE_ENV="production"

# openssl is required for Prisma
RUN apt-get update -qq && \
  apt-get install --no-install-recommends -y openssl

# Copy pre-built application
COPY --link api/build /api/build
COPY --link app/build /app/build

# Start the server by default, this can be overwritten at runtime
CMD [ "node", "build/main.mjs" ]

As you can see I use Prisma. In addition to installing openssl I also had to change my binaryTargets for Fly.io's Linux micro-VMs. These are my binary targets.

generator client {
  binaryTargets = ["debian-openssl-1.1.x", "native", "debian-openssl-3.0.x"]

Prisma threw some very useful errors that made figuring this out really easy. It literally told me to install openssl and which binary targets to use. If you pre-build like I do then you will also need to make sure these binaries get copied to the container. I was already coping these to my app's bundle for Vercel so I didn't have to worry about that in this case.

One last hiccup that I ran into, when migrating my build, was with my server bundle created by Vite for SSR. Previously, when I used vercel build, Vercel would copy the source code and then install the package dependencies. This made node_modules available to my server bundle. However, I didn't really want my server bundle importing node modules. What I really wanted was to just bundle all the dependencies with my server bundle so that I just had one file that gets copied to the container. This was actually much harder to do with Vite than it should have been. Eventually I found the secret sauce to make it work thanks to this GitHub issue.

Moving Application Secrets#

The next challenge I faced was moving my application's secrets. The way Vercel and Fly.io handle secrets is quite different. Vercel has the concept of environments but Fly.io does not. The way you manage different environments with Fly.io is by creating an app for each environment. You can then add secrets to individual apps but there is no way of copying secrets from one app to another. The fly CLI also does not allow you to get the plain text value of a secret after it is added.

It is possible to ssh into the running container and access your secrets as environment variables. This requires a running container though.

Fortunately, Vercel did allow me to read the plain text value of my secrets. So I could easily extract them from Vercel. However, I couldn't simply just add my secrets to my Fly.io app and call it a day. This is because I run my migrations in GitHub Actions and I dynamically create preview apps for open PRs, so I need my secrets in the context of GitHub Actions as well. I could have just added my secrets in GitHub Actions but, again, there is no way to read those secrets after they are added.

I decided it was best to manage my secrets externally. My initial thought was to use AWS's Secrets Manager but for a dozen or so secrets that would cost me roughly $6/month 😔.

Enter Bitwarden, a freemium open-source password manager. I was already using Bitwarden to manage my passwords and I was curious if I could use Bitwarden as a no-cost secrets manager as well. It turns out I can and it actually works really well. Bitwarden provides an API and a CLI. The CLI can be used in automated workflows using an API key. The only annoying thing is I get a new device login email every time I deploy my app and there doesn't seem to be a way to disable it. I'll take that $6/month though and put it towards add-free video streaming. You know, because it is ok to charge people for services and still force them to watch ads 🤬.

Updating CI/CD#

My CI workflow only required minor changes. I pretty much just removed a step where I pulled environment variables from Vercel before building my app since that was no longer necessary. The only other change I had to make was updating the cache directory for my build.

My CD workflow required a bit more work as you might expect. Previously I was using the Vercel CLI to deploy my app to a preview environment for open pull requests. Fortunately, I found this GitHub Action that did exactly what I needed. It creates a new app on Fly.io for open pull requests. Without adding a credit card Fly.io will only let you create 2 apps. So I had to make sure the app was destroyed when the PR was closed.

In addition, I was getting the plain text value of my secrets from Vercel to run my migrations. However, I could not get my secrets in plain text using the fly CLI. Ultimately I decided to use Bitwarden as secrets manager. To do this I created a reusable GitHub Action that takes the environment as input and returns the secrets for that environment as output. It uses the Bitwarden CLI internally to download the secrets.

Porting Vercel Behavior#

I didn't make heavy use of Vercel's configuration but I was using it to perform some redirects and to attach headers to the response. These were straightforward to port to my own web server. However, I was also using the cleanUrls and trailingSlash options. The cleanUrls option prevents navigating directly to an index.html file and the trailingSlash option, in my case, prevented trailing slashes in the URL. These options prevent your content from being duplicated from the perspective of search engines. Luckily these were pretty easy to implement myself.

Another thing that Vercel was doing for me was something that it calls filesystem priority. Essentially, when a request for a file was received, and if that file existed, then Vercel would automatically return that file to the client. This is so common though that most web servers have built in middleware for serving static content. In addition, I was using a non-standard HTTP header set by Vercel that needed to be changed when I moved to Fly.io.

The last thing that tripped me up was TLS. With Vercel the use of TLS was completely opaque, you didn't have to think about it at all. However, with Fly.io that wasn't exactly the case. Fly.io uses a reverse proxy between your app and the client. The connection between the client and the proxy uses TLS but the request is downgraded to http when it reaches your app. You need to be aware of this when performing redirects to make sure you use the correct protocol. Also, when server rendering your app you need to make sure you use the correct protocol. If your app makes a POST request to your server using http that request will be converted to a GET request over https.

And there you have it, migration complete! Maybe I'll migrate my Postgres database off of Neon next for insanely fast reads that will melt your face 🫠.


You will not receive notifications for new comments. If you are waiting for a reply please check back periodically.
Markdown enabled