greg troszak

SQLite migrations with Atlas on Fly.io

2024-09-08

I’ve recently been on a bit of a quest to find the simplest, most boring, infrastructure for deploying a webservice, and it seems pretty difficult to beat some compute (a Fly Machine in this case) and a SQLite database. I also like the idea of declaratively defining my database schema and just having a tool (Atlas) that makes it so.

The interesting part about this is that because the SQLite database has to be stored in a volume attached to the Fly Machine, you can’t run migrations as a seperate task like you normally would with an RDBMS that runs as a server (like MySQL or Postgres). Instead, the migrations need to be run when the machine starts up. The obvious downside of this is that you’re hosed if a migration takes a long time to complete and you need to serve requests. Services just getting off the ground shouldn’t have that problem, so let’s not worry about problems we don’t have. Consider yourself lucky if you do.

Setting up Atlas

Define your schema

Atlas let’s you define your schema using their HCL language, plain old SQL, or via on ORM. I’m a luddite, so I chose SQL.

-- schema.sql

CREATE TABLE customers (
    id INTEGER PRIMARY KEY,
    first_name TEXT NOT NULL,
    last_name TEXT NOT NULL
);

If you already have a database, Atlas can inspect it and generate this file for you.

Apply your change

Let’s apply that change to a non-existent database (that will live at .data/ db). Because I’m using SQL to define my schema, I need to provide a --dev-url to a database that Atlas can use process and validate stuff. You can read more about it here. We can just use an in-memory SQLite database for this.

atlas schema apply \
  --url "sqlite://.data/db" \
  --to "file://schema.sql" \
  --dev-url "sqlite://dev?mode=memory"

Run that and we’re presented with the planned changes, which I’ll apply. Feel free to use the --auto-approve flag if you don’t want to intervene.

-- Planned Changes:
-- Create "customers" table
CREATE TABLE `customers` (
  `id` integer NULL,
  `first_name` text NOT NULL,
  `last_name` text NOT NULL,
  PRIMARY KEY (`id`)
);
? Are you sure?:
  â–¸ Apply
    Lint and edit (requires login)
    Abort

Running the migrations on Fly.io

As previously mentioned, because the SQLite database has to be stored in a volume attached to the Fly Machine, migrations need to be run when the machine starts up.

Setting up Fly.io

Let’s take a look at some of the relevant sections of our fly.toml. I’ve added comments describing the relevance of each line.

# fly.toml

...

[processes]
  # Command invoked to start the application. We'll get back to this later.
  app = 'task start'

...

[mounts]
  # The name of the volume to mount.
  source = "data"
  # The path the volume will be mounted at in the container.
  destination = "/data"

...

[env]
  # An environment variable the application can use to connect to the database.
  DB_PATH = '/data/db'

...

And here’s the Dockerfile for the application image. I’m using Deno, but that’s somewhat irrelevant to this approach. Again, comments added to the relevant lines.

ARG DENO_VERSION=1.46.3
ARG BIN_IMAGE=denoland/deno:bin-${DENO_VERSION}

# Deno binary layer
FROM ${BIN_IMAGE} AS bin

# Atlas binary layer
FROM arigaio/atlas:latest-alpine as atlas

# Runtime layer
FROM frolvlad/alpine-glibc:alpine-3.13

# Make sure SQLite library is installed
RUN apk --no-cache add ca-certificates sqlite-libs

RUN addgroup --gid 1000 deno \
  && adduser --uid 1000 --disabled-password deno --ingroup deno \
  && mkdir /deno-dir/ \
  && chown deno:deno /deno-dir/

ENV DENO_DIR /deno-dir/
ENV DENO_INSTALL_ROOT /usr/local

ARG DENO_VERSION
ENV DENO_VERSION=${DENO_VERSION}

# Copy deno binary from the Deno binary layer
COPY --from=bin /deno /bin/deno
# Copy atlas binary from the Atlas binary layer
COPY --from=atlas /atlas /bin/atlas

WORKDIR /deno-dir
COPY . .

# (Deno specific) Cache Deno dependencies so they aren't redownloaded everytime
# the machine starts.
RUN /bin/deno cache ./src/app.tsx

# Use deno as the entrypoint. If you look back at the `[processes]` section
# of `fly.toml` you'll notice the `task start` bit. Combine that with this
# entrypoint, and `bin/deno task start` is invoked when the container starts up.
ENTRYPOINT ["/bin/deno"]
CMD ["run", "--allow-net", "https://deno.land/std/examples/echo_server.ts"]

Running the migrations and starting up the application

So when the container starts, it invokes the command deno task start. Here’s what that looks like in deno.json.

...
"tasks": {
  "migrate": "deno run -RWE --allow-run migrate.ts",
  "serve": "deno serve -RWNE --allow-ffi --unstable-ffi --port=8080 src/app.tsx",
  "start": "deno task migrate && deno task serve"
},
...

The start task first invokes the migrate task, and if it succeeds invokes the serve task. Note that the migrate task uses a dedicated migrate.ts script. This is purely a convenience to easily access the DB_PATH environment variable set earlier in fly.toml.

// DB_PATH is loaded into configuration, which is then used by scipts and the
// application.
import { config } from "@/config.ts";

const command = new Deno.Command("atlas", {
  args: [
    "schema",
    "apply",
    "--url",
    `sqlite://${config.db.path}`,
    "--to",
    "file://schema.sql",
    "--dev-url",
    "sqlite://dev?mode=memory",
    "--auto-approve",
  ],
});

const { code, stdout, stderr } = await command.output();

const decoder = new TextDecoder("utf-8");
if (code === 0) {
  console.log("Migration applied successfully.");
  console.log(decoder.decode(stdout));
} else {
  console.log("Migration failed.");
  console.log(decoder.decode(stderr));
}

Deploying it

Running fly deploy should deploy everything, but Fly.io will spin up 2 machines by default. That won’t work since you can only attach one machine to a volume. You’ll need to run fly scale count 1 to spin down the second machine.

Making a change

Assuming you’ve kept the schema of your local SQLite DB in sync with the one on Fly.io, you should simply be able to change schema.sql and deploy the change. If they’ve gotten out of sync, get yourself to the last deployed commit, create a fresh SQLite DB, then give the schema change a shot. Or it you want to be extra sure about a migration, you can always use fly ssh sftp to retrieve a copy of the actual SQLite database and try it locally before deploying your change.