Skip to content

Connect a database

deploybase does not host databases. Your functions and your server-rendered app (Astro or Nuxt) connect to one you create with a database provider, using a URL and a token you store as environment variables.

One rule decides which databases work: functions reach the internet over HTTPS on port 443 only (outbound network). A driver that speaks HTTPS or WebSockets connects. A driver that opens a raw TCP connection, such as Postgres on port 5432 or MySQL on 3306, cannot.

This guide uses Bunny Database, a SQLite-compatible database that answers over HTTPS and can keep its data in Frankfurt. The steps assume HTTP functions or a server-rendered app, both of which need the Starter plan or above. In the dashboard, the project’s Database page walks through the same steps and tests the connection before saving anything.

  1. In the bunny.net dashboard, choose Add, then Database.
  2. Enter a name and choose the Manual deployment mode.
  3. Set the storage location to Frankfurt, DE. It is the only location in the EU; the other is Toronto.
  4. Choose an EU primary region, such as France or Germany. Your functions run in Paris, so a primary nearby keeps each query short.
  5. Leave replica regions empty. A replica outside the EU copies your data there, and it does not make functions in Paris any faster.
  6. Open the database’s Access page and copy the Database URL and a Full Access token. The token is shown once.

The URL looks like this, with your own database ID in place of the zeros:

libsql://0123456789abcdef0123456789abcdef.lite.bunnydb.net

Create a second database the same way for branch previews and local development, so a preview can never change production data. A read-only token on the production database is not a substitute: anything that writes, a login included, fails with it.

On the project’s Environment page, add two variables for each database. The names are the ones Bunny’s own tools write, so Bunny’s examples work unchanged:

Key Production database Preview database
BUNNY_DATABASE_URL Scope Production, Runtime and Build ticked Scope Preview, Runtime only
BUNNY_DATABASE_AUTH_TOKEN Scope Production, Runtime and Build ticked Scope Preview, Runtime only

Build on the production pair is there so a production build can run your migrations. If you run migrations yourself, leave it unticked: a variable with Build ticked is visible to everything the build runs, dependencies included. The preview pair stays Runtime only, which means no preview build ever sees a database token.

Over the API, the production token is:

Terminal window
curl -X POST "https://api.deploybase.eu/api/v1/projects/$PROJECT_ID/env" \
-H "Authorization: Bearer $DEPLOYBASE_API_KEY" \
-H "Content-Type: application/json" \
-d "{\"key\": \"BUNNY_DATABASE_AUTH_TOKEN\", \"value\": \"$BUNNY_TOKEN\", \"environment\": \"production\", \"targets\": [\"runtime\", \"build\"]}"

When a change reaches your code differs: HTTP functions pick it up without a deploy, while a server-rendered app (Astro, Nuxt) keeps the values it was built with until you deploy again. HTTP functions run in production only, so they always get the production database.

Install the libSQL client:

Terminal window
npm install @libsql/client

Import it from @libsql/client/web, not from @libsql/client. The default entry loads a native SQLite module, and neither functions nor a server-rendered app can load native modules. The web entry speaks HTTPS and nothing else.

From an HTTP function, read the variables from context.env:

functions/notes.js
import { createClient } from "@libsql/client/web";
export default async function handler(request, context) {
const db = createClient({
url: context.env.BUNNY_DATABASE_URL,
authToken: context.env.BUNNY_DATABASE_AUTH_TOKEN,
});
const { rows } = await db.execute("SELECT id, body FROM notes ORDER BY id DESC LIMIT 20");
return Response.json(rows);
}

The client adds about 270 KB to the function’s bundle, well inside the 900 KB limit.

From an Astro server, read them with getSecret(), and create the client on first use so that pages prerendered at build time never need it:

src/pages/api/notes.ts
import type { APIRoute } from "astro";
import { getSecret } from "astro:env/server";
import { createClient } from "@libsql/client/web";
export const prerender = false;
let client: ReturnType<typeof createClient> | undefined;
function db() {
client ??= createClient({
url: getSecret("BUNNY_DATABASE_URL") ?? "",
authToken: getSecret("BUNNY_DATABASE_AUTH_TOKEN"),
});
return client;
}
export const GET: APIRoute = async () => {
const { rows } = await db().execute("SELECT id, body FROM notes ORDER BY id DESC LIMIT 20");
return Response.json(rows);
};
export const POST: APIRoute = async ({ request }) => {
const body = String((await request.formData()).get("body") ?? "").slice(0, 500);
await db().execute({ sql: "INSERT INTO notes (body) VALUES (?)", args: [body] });
return new Response(null, { status: 201 });
};

From a Nuxt server route, read them from process.env:

server/api/notes.get.ts
import { createClient } from "@libsql/client/web";
let client: ReturnType<typeof createClient> | undefined;
function db() {
client ??= createClient({
url: process.env.BUNNY_DATABASE_URL ?? "",
authToken: process.env.BUNNY_DATABASE_AUTH_TOKEN,
});
return client;
}
export default defineEventHandler(async () => {
const { rows } = await db().execute("SELECT id, body FROM notes ORDER BY id DESC LIMIT 20");
return rows;
});

Drizzle works on top of the same client: import drizzle from drizzle-orm/libsql/web.

When several statements must succeed or fail together, send them with batch(), which runs them as one transaction in a single request:

await db.batch(
[
{ sql: "INSERT INTO orders (id, total) VALUES (?, ?)", args: [orderId, total] },
{ sql: "UPDATE stock SET count = count - 1 WHERE sku = ?", args: [sku] },
],
"write",
);

Use transaction(), or Drizzle’s db.transaction(), only when a later statement depends on what an earlier one read. It works on Bunny Database, but every statement in it is a separate request from Paris to the database, and the database holds its write lock until you commit. batch() sends everything at once.

The web client does not open local file: databases, so connect your machine to the preview database. Put its URL and token in a .env file at the project root, which astro dev reads:

Terminal window
BUNNY_DATABASE_URL=libsql://0123456789abcdef0123456789abcdef.lite.bunnydb.net
BUNNY_DATABASE_AUTH_TOKEN=<the preview database's token>

Keep .env out of Git. On your machine, the Bunny CLI can write this file for you: bunny db tokens create offers to save both values to .env.

Keep your schema as migration files in the repository and apply them as the first step of every production build. The Bunny CLI’s bunny db migrations apply cannot do this on deploybase: it is a binary for glibc Linux, and builds run on Alpine, where it fails to start. Use a script that runs in Node instead.

With Drizzle, generate migrations on your machine with drizzle-kit generate, commit the drizzle/ directory, and add this script:

scripts/migrate.mjs
import { createClient } from "@libsql/client/web";
import { drizzle } from "drizzle-orm/libsql/web";
import { migrate } from "drizzle-orm/libsql/migrator";
const url = process.env.BUNNY_DATABASE_URL;
if (!url) {
console.log("BUNNY_DATABASE_URL is not set for this build: skipping migrations");
process.exit(0);
}
const client = createClient({ url, authToken: process.env.BUNNY_DATABASE_AUTH_TOKEN });
await migrate(drizzle(client), { migrationsFolder: "drizzle" });
console.log("Migrations applied");

Run it before the build in package.json, and keep the project’s build command at npm run build:

{
"scripts": {
"build": "node scripts/migrate.mjs && astro build"
}
}

The migrator sends all pending migrations as one batch, so they apply together or not at all, and a failed migration fails the build before anything is deployed. Preview builds skip the step because the preview pair is not ticked for Build. Apply migrations to the preview database from your machine, with the same script and its URL and token in your shell.

Migrations run before the new deployment goes live, and rolling back does not undo them. Write each one so the deployment before it still works: add a column or a table in one release, and drop the old one in a later release.

  • Up to 10 seconds of writes can be lost. Bunny persists writes every 10 seconds or 4096 page changes, and writes not yet persisted can be lost if its primary fails over (durability).
  • SQLite, with a few statements removed. VACUUM, ANALYZE, ATTACH DATABASE and CREATE TEMP TABLE are rejected, foreign keys are enforced by default, and a double-quoted "text" is always an identifier, never a string (connection settings).
  • Cold starts add up. A function or server-rendered app that has scaled to zero starts first and then opens the connection, so the first request after a quiet spell is the slowest one. The start times are in the functions reference.
  • Replacing a token. Generate a new token, save it on the Environment page, and redeploy if you use a server-rendered app; then invalidate the old token. On the database’s Access page, Bunny warns that Regenerate Tokens invalidates tokens for all your databases, while its CLI’s bunny db tokens invalidate acts on one database.

The database is a contract between you and Bunny, under Bunny’s terms and DPA: it is your processor, not ours. deploybase stores only the URL and token, encrypted, and hands them to your code. Queries go from your function in Paris straight to Bunny. If your app stores personal data, add Bunny to your own list of processors.

Any database whose client talks HTTPS or WebSockets on port 443 works the same way: store its credentials as Runtime variables and read them from context.env or getSecret(). Examples are Neon’s serverless driver, Supabase’s JavaScript client and Upstash Redis’s REST client. Check where each provider stores your data and which company runs it before you choose. A database reachable only over Postgres or MySQL wire protocol, such as most managed Postgres services, cannot be reached from a function today.

Live 022265e7 built