From nothing to Fly.io — the eezo deploy walkthrough#
The whole arc: scaffold an app, give it a model, ship it. Every output below is from a real run (2026-09-09, first field deploy). Time budget: ~15 minutes, most of it Fly's first image build.
What you will not do at any point: write a Dockerfile, run Docker, write deployment YAML, run migration SQL by hand, or SSH into anything.
0. Prerequisites#
- Fly account + CLI.
curl -L https://fly.io/install.sh | sh, add~/.fly/bintoPATH, thenfly auth signup(orfly auth login). - The eezo command line:
curl -fsSL https://eezo.io/install | bash, as in your first application.
1. Scaffold#
eezo new bookshelf
cd bookshelf
eezo routes1 route:
GET /That's the whole app: a one-override Main extends EezoApp, one handwritten route under app/,
and the build files. No deploy configuration exists yet, anywhere.
2. See it run#
eezo devServes on 8080 (add override def port: Int = <n> in Main if 8080 is taken), restarts on every
save, and keeps serving the old code if you save a compile error. Two URLs worth visiting:
http://localhost:8080/— the scaffold pagehttp://localhost:8080/eezo/health—ok. The framework answers this on every eezo server; it is what Fly's health checks and the deploy's post-deploy poll will hit.
3. A model, and its migration#
src/main/scala/models/Book.scala:
package models
import io.eezo.core.Id
import io.eezo.db.Table
import io.eezo.http.{Form, Resource}
case class Book(
id: Id[Book],
title: String,
author: String,
pages: Int
) derives Table,
Form,
Resourcesrc/main/scala/AppSchema.scala:
import io.eezo.db.Schema
import models.Book
object AppSchema extends Schema {
val books = table[Book].unique(_.title)
}And name the schema in Main.scala:
import io.eezo.db.Schema
// inside Main:
override def schema: Schema = AppSchemaeezo routes now shows 8 routes — yours plus seven derived CRUD routes at /books. Freeze the
schema as the first migration (no database needed — freeze diffs the committed snapshot against
your code):
eezo freeze initial schema2 change(s) since last freeze:
create table book
+ index uq_book_title on book
wrote db/migrations/0001_initial_schema.sqlThat file is committed SQL with a fingerprint header, and it is exactly what will run on Fly.
4. First deploy — designed to stop halfway#
eezo deploy→ first deploy: creating the Fly app
Created app 'bookshelf-…' in organization '…'
→ wrote fly.toml (app: bookshelf-…, region: fra)
✗ deployment cannot continue: no database secret is set.Three things happened: Fly created the app and picked your nearest region (fly launch under the
hood — that detection is Fly's, not eezo's), eezo wrote fly.toml (yours: committed, editable,
rewritten only if you delete it), and the deploy stopped on purpose — this app has
migrations, migrate --apply will run on deploy, and there is no database yet.
The interesting line in the generated fly.toml:
[deploy]
release_command = 'migrate --apply'This is eezo's migration ordering, delegated to the platform: Fly runs the command in a one-off machine with the new image, before any serving machine is updated. A nonzero exit stops the deployment and the old version keeps serving. The schema converges before new code takes traffic.
5. The database#
fly postgres create --name bookshelf-db --region fra # pick "Development" (single node)
fly postgres attach bookshelf-db --app <your-app-name>attach creates a database + user inside the cluster and sets the DATABASE_URL secret on your
app. Do not save the printed URL anywhere — it lives in Fly's secret store, which is the point;
eezo parses DATABASE_URL natively (it also understands EEZO_DB_URL/_USER/_PASS). On a
platform URL eezo requires TLS unless the URL says otherwise with its own sslmode, and Fly's
attach says otherwise on purpose with sslmode=disable, so a database elsewhere that only speaks
clear text needs sslmode=disable added to its URL or a raw JDBC url in EEZO_DB_URL. Verify
without exposing anything:
fly secrets listOne row, DATABASE_URL, digest only. Staged status is normal — there are no machines yet.
EEZO_DB_POOL_SIZE is the number of connections in the pool, default 10.
EEZO_DB_ACQUIRE_TIMEOUT is how many milliseconds a request waits for a connection before it fails with a 500, default 5000.
6. Deploy for real#
eezo deploy→ staging the application
→ deploying to Fly (migrations run before the new version takes traffic)
… Fly's build output (remote builder; no local Docker involved) …
… release_command: migrate --apply → 0001_initial_schema.sql, applied ✓,
database matches model ✓ (on some flyctl versions this appears in the Fly
dashboard's release logs rather than inline) …
→ waiting for health at https://<app>.fly.dev/eezo/health
✓ deployed and healthy
https://<app>.fly.devBehind the scenes, eezo build staged target/eezo/stage/ — every runtime jar, your
db/migrations, and a generated Dockerfile (JRE-only, no sbt) — and Fly's remote builder turned
it into the image. The first build is the slow one (base image pull); later deploys are much
faster.
7. Prove it#
Open https://<app>.fly.dev/books. Create a book through the derived form, read it, delete it.
Those rows live in your Fly Postgres, written through JdbcStore into the table your migration
created. /eezo/health answers ok; anything unknown 404s with eezo's problem page.
8. The loop from here#
Change the model → eezo freeze <name> → eezo deploy. The new migration ships inside the
image and runs in the release machine before the new code serves. If it fails, the deploy fails,
the old version keeps serving, and the migration's own output says why. eezo deploy with
nothing pending prints no pending migrations in the release step and just ships code.
When the new version replaces a machine, Fly signals the old one to stop. The server stops taking new requests at once, gives the ones already running up to three seconds to finish, and only then closes the database, so a request inside a transaction completes instead of losing its connection halfway.
CI is the same two commands, non-interactively: authenticate with FLY_API_TOKEN, and pass
--app <name> on the first-ever deploy (interactive prompts are refused when there's no TTY).
Troubleshooting, from the field#
eezo routesshows fewer routes than expected — check the generator's warnings. The scan mounts top-level models (indented or not) and models nested in objects (asOuter.Inner); a model nested in a class, or an indented one in a significant-indentation file, cannot be mounted by name and warns, naming the enclosure and theResource.routesOfescape hatch.- Health checks never go green —
internal_portinfly.tomlmust equal the app'sport(default 8080). They drift when you override one and not the other. - Migration failed — the deploy stopped before traffic switched; the old version is still
serving.
fly logs(or the release logs in the Fly dashboard) has the migration's own output. - Where are the deploy logs? —
fly deploy's stream, plus the Fly dashboard for the release machine's logs.eezodeliberately does not swallow Fly's output during deploy.