Migrating to Fly.io
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 maintaining 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 over 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 time out, 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 could 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 full-stack 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 necessarily 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
ARG NODE_VERSION=20.5.0FROM node:${NODE_VERSION}-slim as base
LABEL fly_launch_runtime="Node.js/Prisma"
ENV NODE_ENV="production"
# openssl is required for PrismaRUN apt-get update -qq && \ apt-get install --no-install-recommends -y openssl
# Copy pre-built applicationCOPY --link api/build /api/buildCOPY --link app/build /app/build
# Start the server by default, this can be overwritten at runtimeEXPOSE 4000WORKDIR /apiCMD [ "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 adding 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 to import 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 got 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 to copy 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 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 that 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 two 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, prevents 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 the server renders 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 🫠.
Written by Daniel Nagy
Daniel is a software engineer and full-stack web developer. He studied computer science at Ohio University and has been doing web development and hybrid mobile app development since 2014.
If you liked this post, then please consider donating or becoming a sponsor. Your support will help me produce more content that gives back to the community.
Comments
You will not receive notifications for new comments. If you are waiting for a reply, please check back periodically.