Skip to content

MySQL

Kysely_

Use Kysely with an Appwrite native MySQL database. Configure MysqlDialect with mysql2, run migrations against the direct connection, and pool runtime traffic from serverless environments.

4 min read

Raw

Appwrite's native MySQL database is a standard MySQL engine (default 8.4), so Kysely works against it with no Appwrite-specific dialect or driver. Point MysqlDialect and a mysql2 pool at the connection string from the Connections page and use the type-safe query builder as you would against any MySQL server.

Set the connection string

Fetch the connection details with mysql.get(). That call returns one connectionString on the engine port (3306). Use it as DIRECT_URL, copy it to DATABASE_URL, and change only the port to 6033 for the connection pooler. Never commit them:

.env
DATABASE_URL="mysql://admin.<hash>:<password>@db-<hash>.<region>.appwrite.center:6033/default"
DIRECT_URL="mysql://admin.<hash>:<password>@db-<hash>.<region>.appwrite.center:3306/default"

Use the pooled DATABASE_URL (port 6033) for runtime traffic and DIRECT_URL (port 3306) for migrations and CLI tools. Connections on Appwrite Cloud are encrypted with TLS. If mysql2 does not infer TLS from the connection string, pass ssl options on the pool and keep certificate verification enabled against Node's standard trust store. See Network security for TLS and network controls.

Install and configure Kysely

Install Kysely and mysql2:

Bash
npm install kysely mysql2
npm install -D typescript kysely-ctl

Create a typed database interface and a Kysely instance backed by MysqlDialect. Mark database-generated columns with Generated<T> so insert-only payloads typecheck:

TypeScript
import { Generated, Kysely, MysqlDialect } from 'kysely';
import { createPool } from 'mysql2';
interface UsersTable {
id: Generated<number>;
email: string;
created_at: Generated<Date>;
}
interface Database {
users: UsersTable;
}
const dialect = new MysqlDialect({
pool: createPool({
uri: process.env.DATABASE_URL,
ssl: { rejectUnauthorized: true },
connectionLimit: 10
})
});
export const db = new Kysely<Database>({ dialect });

ssl: { rejectUnauthorized: true } validates the server certificate against Node's built-in CA store. You can also pass discrete host, port, user, password, and database fields to createPool instead of uri.

Run migrations

Prefer kysely-ctl for the migration happy path. Point it at DIRECT_URL on the direct engine port (3306). Migrations issue DDL that needs a session-level connection rather than the transaction-mode pooler.

Create kysely.config.ts (project root or .config/):

TypeScript
import { MysqlDialect } from 'kysely';
import { defineConfig } from 'kysely-ctl';
import { createPool } from 'mysql2';
export default defineConfig({
dialect: new MysqlDialect({
pool: createPool({
uri: process.env.DIRECT_URL,
ssl: { rejectUnauthorized: true }
})
}),
migrations: {
migrationFolder: 'migrations'
}
});

Generate and apply migrations:

Bash
npx kysely migrate make create_users
npx kysely migrate latest

A migration file that creates the users table:

TypeScript
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.createTable('users')
.addColumn('id', 'integer', (col) => col.primaryKey().autoIncrement())
.addColumn('email', 'varchar(255)', (col) => col.notNull().unique())
.addColumn('created_at', 'timestamp', (col) =>
col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`)
)
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema.dropTable('users').execute();
}

After the schema exists, you can optionally generate TypeScript types with kysely-codegen (npx kysely-codegen) instead of hand-writing table interfaces. Keep Generated<T> on database-generated columns either way.

Alternate: Migrator API

If you prefer not to use kysely-ctl, Kysely's built-in Migrator works the same way against DIRECT_URL:

TypeScript
import { promises as fs } from 'fs';
import { Kysely, MysqlDialect } from 'kysely';
import { FileMigrationProvider, Migrator } from 'kysely/migration';
import { createPool } from 'mysql2';
import * as path from 'path';
const db = new Kysely({
dialect: new MysqlDialect({
pool: createPool({
uri: process.env.DIRECT_URL,
ssl: { rejectUnauthorized: true }
})
})
});
const migrator = new Migrator({
db,
provider: new FileMigrationProvider({
fs,
path,
migrationFolder: path.join(__dirname, 'migrations')
})
});
const { error, results } = await migrator.migrateToLatest();
await db.destroy();
if (error) {
throw error;
}

The admin.<hash> user owns the default database and can run schema changes.

Query with Kysely

Once the schema is migrated, use the query builder. MySQL insert results do not return the full row by default, so read the row back when you need it:

TypeScript
import { db } from './db';
const insertResult = await db
.insertInto('users')
.values({ email: 'ada@example.com' })
.executeTakeFirstOrThrow();
const user = await db
.selectFrom('users')
.selectAll()
.where('id', '=', Number(insertResult.insertId))
.executeTakeFirstOrThrow();
const recent = await db
.selectFrom('users')
.selectAll()
.orderBy('created_at', 'desc')
.limit(10)
.execute();

On long-running servers, create the Kysely instance once at module scope and reuse it. On serverless, keep a single instance per module scope so warm invocations reuse the pool, and use the Appwrite pooler for runtime traffic. Call db.destroy() on shutdown.

Pool connections from serverless

Each running instance opens its own connections to the engine. On serverless and edge platforms such as Vercel, Netlify, and Cloudflare, short-lived instances can fan out into more backend connections than the engine allows. Route runtime traffic through the connection pooler. The primary DATABASE_URL already uses the pooler port (6033) on the same hostname.

Keep kysely-ctl (and any Migrator script) pointed at DIRECT_URL so DDL still runs over a session-level connection.

Size the mysql2 pool conservatively for serverless (often a connectionLimit of 1 or a small single-digit value per isolate). Prefer normal parameterized queries over session-scoped prepared statement workflows when you use transaction pooling. Session-level features such as user variables and temporary tables need the direct port or a session-mode pooler. See the pooler modes page for the trade-offs. Confirm the pooler port on the Connections page if your project shows a different value.

Use a branch for previews and CI

Branches are isolated copies of a database with their own hostname and connection string. They're ideal for running migrations against throwaway data in a pull-request preview or an integration-test job:

  1. Create a branch from the API and read its connectionString.
  2. Export it as DIRECT_URL and DATABASE_URL for the preview or CI job.
  3. Run kysely migrate latest (or migrateToLatest) and your test suite against the branch.
  4. Delete the branch when the job finishes.

Because a branch starts from a storage snapshot, the schema and data match the source database at branch time, so migrations run against representative data without touching production.

Was this page helpful?

Share what worked or what we should fix. Once approved, our agents automatically apply suggested updates to the docs.