Migrate Next.js app away from "Vercel + PlanetScale" to "Fly.io"

In other words: How to deploy a Next.js app (SSR mode) with MySQL as a database, to Fly.io



In this article, I will show you a step-by-step guide on deploying a Next.js app (SSR mode) to Fly.io. We will deploy a MySQL database to Fly.io as well. Although you have the option to choose other databases, I will be using MySQL in this case as I am migrating away from PlanetScale, which is a MySQL database under the hood.

What does the result look like?

In the end, your application architecture will be:

  • Next.js app (SSR mode) running on a VM (via Docker container) in Fly.io.
  • MySQL database also running on Fly.io, with an attached persistent volume, and residing in the same private network as the Next.js app.
  • We will use Cloudflare as a DNS and handle non-www to www redirection (i.e., redirect crispyscript.com to www.crispyscript.com).

Before and after, architecture

Step 1: [MySQL] Initialize Fly.io setup for MySQL

For reference, you can consult the documentation.

  1. Begin by creating a new directory. This can be nested within the Next.js project's directory or in another location on your computer.

    mkdir my-mysql
    cd my-mysql
    
  2. Initialize the Fly.io setup.

    fly launch --no-deploy --image mysql:8
    
  3. Edit the fly.toml file generated by fly launch:

    • Remove the entire [http_service] block.
    • Add MYSQL_DATABASE and MYSQL_USER into the [env] block.
    • Add [processes] and [mounts] blocks.

    The final fly.toml file should resemble the following (note that I've chosen the "shared-cpu-1x" machine with "1gb" memory):

    app = 'my-mysql'
    primary_region = 'sin'
    
    [build]
      image = 'mysql:8'
    
    [[vm]]
      size = 'shared-cpu-1x'
      memory = "1gb"
    
    [processes]
      app = """--datadir /data/mysql \
        --default-authentication-plugin mysql_native_password"""
    
    # We will create `mysqldata` with `fly volumes` later
    [mounts]
      source = "mysqldata"
      destination = "/data"
    
    # For non-sensitive env variables only!
    # The other sensitive env variables(including `MYSQL_PASSWORD`) are in `fly secrets`
    [env]
      MYSQL_DATABASE = "blog"
      MYSQL_USER = "any_username"
    

Step 2 (Optional): [MySQL] Set up auto start/stop feature

Edited: I found that sometimes the auto-start feature does not work properly. It causes an unexpected error when trying to connect to MySQL via *.flycast address. My recommendation is to skip this step until the issue is fixed.


The original content:

For reference, you can refer to Q&A 1 | Q&A 2 | The Documentation.

This step is optional.

You can configure Fly.io to automatically stop a MySQL container when there is no traffic to save costs.

  1. Add [[services]] and [[services.ports]] blocks to fly.toml file.

    # Add this to the end of `fly.toml` file in "Step 1"
    
    [[services]]
      internal_port = 3306
      protocol = "tcp"
      processes = ["app"]
      auto_stop_machines = true
      auto_start_machines = true
      min_machines_running = 0
    
      [[services.ports]]
        handlers = []
        port = 3306
    
  2. Complete "Step 3" to "Step 5" to deploy MySQL for the first time. Then, return here and proceed to "3." below.

  3. While still in the my-mysql directory, execute this command:

    fly ips allocate-v6 --private
    

    The result should resemble this when you run the fly ips list command:

    VERSION  IP                  TYPE      REGION    CREATED AT
    v6       fdaa:0:22b7:0:1::3  private   global    just now
    

Now, for the remainder of this article, replace any of my-mysql.internal with my-mysql.flycast.

Step 3: [MySQL] Create a volume

For reference, consult the documentation.

In the my-mysql directory, run the following command to create the mysqldata volume. I've opted for 1GB of disk space, as my website is relatively simple.

# Create a volume named "mysqldata" within our app "my-mysql"
fly volumes create mysqldata --size 1 # gb

The volume result in Fly.io dashboard

Step 4: [MySQL] Add sensitive env variables into fly secrets

For reference, refer to the documentation.

The MySQL container requires two sensitive environment variables: MYSQL_PASSWORD and MYSQL_ROOT_PASSWORD. Place them into fly secrets.

# Set secrets:
# MYSQL_PASSWORD      - password set for user $MYSQL_USER (which we already set the value in "Step 1")
# MYSQL_ROOT_PASSWORD - password set for user "root"
fly secrets set MYSQL_PASSWORD=your_password MYSQL_ROOT_PASSWORD=your_root_password

You can check the result by:

fly secrets list

Step 5: [MySQL] Deploy MySQL to Fly.io

Finally, run this command in the my-mysql directory:

fly deploy

MySQL is successfully deployed as shown in Fly.io dashboard

Caveat: Upon the first deployment, if any mistakes were made or if the username/password weren't set properly, broken data may persist in the volume, and subsequent deployments may still read this broken data. To fix this, you can remove the mysqldata volume and recreate it from scratch.

Step 6 (Optional): [PlanetScale Migration] Migrate data from PlanetScale to MySQL (in Fly.io)

This step is optional.

For people who want to migrate the existing data in PlanetScale to Fly.io, the following steps are what you're looking for.

  1. Connect your local machine to MySQL in Fly.io. You'll need to install WireGuard and create a Fly.io tunnel. Refer to the documentation.

    1. Install WireGuard

    2. In the my-mysql directory, run:

      fly wireguard create
      
    3. Follow the instructions and save the output as a file, basic.conf, for instance:

      !!!! WARNING: Output includes private key. Private keys cannot be recovered !!!!
      !!!! after creating the peer; if you lose the key, you’ll need to remove    !!!!
      !!!! and re-add the peering connection.                                     !!!!
      ? Filename to store WireGuard configuration in, or 'stdout':  basic.conf
      Wrote WireGuard configuration to 'basic.conf'; load in your WireGuard client
      

      Note: don't push this file, basic.conf, to GitHub!

    4. Run the WireGuard app. Click the Import tunnel(s) from file button. Select your configuration file. After successfully imported, Activate it.

    5. Now, you should be able to connect to my-mysql.internal:3306, using your MYSQL_USER, MYSQL_PASSWORD, and MYSQL_DATABASE. Try it with any MySQL clients, e.g. TablePlus, cli tools.

  2. Back up all schemas and data from PlanetScale. Refer to the documentation.

    pscale db dump <DATABASE_NAME> <BRANCH_NAME>
    

    For example, if your database name in PlanetScale is planetblog:

    pscale db dump planetblog main
    

    You will receive a directory name similar to pscale_dump_planetblog_main_20240305_144231. Within the directory, you'll find a planetblog.TableName-schema.sql file containing the schema for the database, as well as one or more files with names like planetblog.TableName.00001.sql, which contain the data. Note: Do not push the pscale_dump_* directory to GitHub!

  3. Restore the backup to MySQL in Fly.io.

    1. Install mydumper

      brew install mydumper
      
    2. Create a db.ini file to provide all necessary information on how to connect to Fly.io MySQL from the local machine. Note: Do not push this file to GitHub!

      [myloader]
      host = my-mysql.internal
      user = any_username
      password = your_password
      database = blog
      port = 3306
      
    3. Execute this command:

      myloader --defaults-file db.ini --directory pscale_dump_planetblog_main_20240305_144231/
      

Now, all data between PlanetScale and Fly.io MySQL should be identical.

Step 7: [Next.js] Initialize Fly.io setup and set NEXT_PUBLIC_* env variables

For reference, consult the documentation.

After signing in to Fly.io and installing their CLI tool (flyctl), navigate to the directory containing your Next.js app. Run fly launch to initialize the Fly.io setup. For example, if the Next.js project is in the my-next-app directory, execute:

cd my-next-app
fly launch

You should get Dockerfile and fly.toml files. Next, add all your NEXT_PUBLIC_* env variables into the Dockerfile below the FROM base as build line.

In the end, your Dockerfile should resemble this:

ARG NODE_VERSION=21.6.2

FROM node:${NODE_VERSION}-slim as base
LABEL fly_launch_runtime="Next.js/Prisma"
WORKDIR /app
ENV NODE_ENV="production"
ARG PNPM_VERSION=8.15.4
RUN npm install -g pnpm@$PNPM_VERSION

FROM base as build

# ----- All `NEXT_PUBLIC_*` env variables go here -----
ARG NEXT_PUBLIC_WEB_URL="https://crispyscript.com"
# -----------------------------------------------------

RUN apt-get update -qq && \
    apt-get install --no-install-recommends -y build-essential node-gyp openssl pkg-config python-is-python3
COPY --link package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile --prod=false
COPY --link prisma .
RUN npx prisma generate
COPY --link . .
RUN pnpm run build
RUN pnpm prune --prod

FROM base
RUN apt-get update -qq && \
    apt-get install --no-install-recommends -y openssl && \
    rm -rf /var/lib/apt/lists /var/cache/apt/archives
COPY --from=build /app /app
EXPOSE 3000
CMD [ "pnpm", "run", "start" ]

Tip: If you plan to connect your Next.js app with PlanetScale later, you need to apt-get install ca-certificates since PlanetScale requires a SSL connection. Edit the last section in Dockerfile:

... (no change)

FROM base
RUN apt-get update -qq && \
    # ------ Add ca-certificates to the next line ------
    apt-get install --no-install-recommends -y openssl ca-certificates && \
    rm -rf /var/lib/apt/lists /var/cache/apt/archives
COPY --from=build /app /app

... (no change)

Additionally, the fly.toml file should resemble this (note that I've selected the "shared-cpu-1x" machine with "512mb" of memory):

app = 'my-next-app'
primary_region = 'sin'

[build]

[http_service]
  internal_port = 3000
  force_https = true
  auto_stop_machines = true
  auto_start_machines = true
  min_machines_running = 0
  processes = ['app']

[[vm]]
  size = 'shared-cpu-1x'
  memory = "512mb"

Step 8: [Next.js] Set up remaining env variables

For reference, refer to the documentation.

There are two groups of environment variables for the Next.js app:

  1. Group 1: NEXT_PUBLIC_* variables - Put them into the Dockerfile as mentioned in "Step 7" above.
  2. Group 2: All runtime variables including sensitive variables, e.g., DATABASE_URL, NEXTAUTH_URL, CLOUDINARY_API_KEY - Place them into fly secrets in this step.

In the my-next-app directory, execute this command to place all "Group 2" variables into fly secrets one by one:

fly secrets set NEXTAUTH_URL=https://crispyscript.com
fly secrets set CLOUDINARY_API_KEY=xxxxx
...

For the DATABASE_URL variable, use a connection string following this pattern: mysql://{MYSQL_USER}:{MYSQL_PASSWORD}@my-mysql.internal/{MYSQL_DATABASE}. Prisma will use this variable to connect to MySQL. If you use another ORM, please adjust accordingly.

fly secrets set DATABASE_URL='mysql://any_username:[email protected]/blog'

Note:

  • If you've set up Flycast in "Step 2 (Optional)", use my-mysql.flycast instead of my-mysql.internal for DATABASE_URL.
  • We aren't utilizing SSL connection between our Next.js app and MySQL since they are within the same private network.

To list all variable names, which have been set so far:

fly secrets list

Fly.io dashboard -> "Secrets" menu

Step 9: [Next.js] Install sharp

Since we'll be self-hosting the Next.js app on our VM instead of utilizing the Vercel platform, we need the sharp package for image optimization feature.

pnpm add sharp

Step 10: [Next.js] Deploy the Next.js app to Fly.io

In the my-next-app directory, simply run:

fly deploy

You'll receive the deployed URL (e.g., https://my-next-app.fly.dev) as a result.

Note: You can utilize fly logs to debug or access the tailing log of the Next.js app.

Step 11: [DNS] Set up Cloudflare to redirect non-www to www URL

If you're not already using Cloudflare as your DNS provider, go through their documentation to set it up. For example, I set up Cloudflare as a DNS provider with a Namecheap domain by following this documentation.

After setting up Cloudflare as your DNS provider, there are a few more steps:

  1. In the my-next-app directory, execute fly certs add for both the www and non-www URLs. Refer to the documentation for guidance.

    fly certs add crispyscript.com
    fly certs add www.crispyscript.com
    

    "Certificates" menu in Fly.io dashboard

  2. Navigate to the Cloudflare dashboard -> "DNS" menu -> "Records" menu.

  3. Add the following 2 records:

    Type      Name      Content                 Proxy status      TTL
    CNAME     @         my-next-app.fly.dev     Proxied           Auto
    CNAME     www       my-next-app.fly.dev     Proxied           Auto
    

    "Records" menu in Cloudflare dashboard

  4. Go to the Cloudflare dashboard -> "SSL/TLS" menu -> "Overview" menu.

  5. Select "Full - Encrypts end-to-end, using a self-signed certificate on the server." This step is crucial. The default option "Flexible" causes a 520 error on my website. Refer to this solution.

    "SSL/TLS" menu in Cloudflare dashboard

  6. Navigate to the Cloudflare dashboard -> "Rules" menu -> "Page Rules" menu.

  7. Create a page rule to redirect non-www to www:

    URL:
    crispyscript.com/*
    
    Then the settings are:
    Forwarding URL - 301 Permanent Redirect
    
    Destination URL:
    https://www.crispyscript.com/$1
    

    "Page Rules" menu in Cloudflare dashboard

Final Result

After going through all steps, the final result would be:

  1. All of these URLs:

    http://crispyscript.com
    https://crispyscript.com
    http://www.crispyscript.com
    https://www.crispyscript.com
    

    should be redirected to:

    https://www.crispyscript.com
    
  2. There will be two apps on Fly.io: the Next.js app (named my-next-app) and MySQL (named my-mysql). You can check their status on the Fly.io dashboard.

    Two apps are shown in Fly.io dashboard

Next Steps

You can also set up a custom email address (e.g., [email protected]) and integrate it with your free Gmail account. This enables you to "send" and "receive" emails on behalf of the custom email address. Follow this instruction if you're interested.