Do It Yourself Database CDN with Embedded Replicas

Jamie BartonJamie Barton
Cover image for Do It Yourself Database CDN with Embedded Replicas

Imagine you have a user in Singapore, and your database is in the US. Every time the user makes a request, it has to travel halfway around the world, which can lead to high latency and poor performance.

This is exactly the problem Turso was first introduced to solve — SQLite at the edge. Recently, Turso doubled down on the local-first approach with embedded replicas, and soon, offline writes.

Embedded Replicas work by maintaining a local SQLite file that you can read from, with zero network latency. This setup reduced the need for up to 70% of Turso users to replicate data and paying for additional locations, since the data could live on the server, or a user's device.

As part of Turso's doubling down on a local-first strategy, and rewriting SQLite, Turso Cloud made the decision to deprecate edge replicas for new users. However, if you want even more control, you can build your own edge replicas, which we'll explore in this post.

If you want to skip the walkthrough, you can follow the turso-cdn quickstart.

#Setting Up

When a libSQL client makes a request to Turso, it's routed to the nearest replica via Fly.io's anycast networking.

Client (Asia)     Client (US)    Client (EU)
     |               |              |
     v               v              v
[Fly Anycast IP - Global Load Balancing]
     |               |              |
     v               v              v
[sin proxy]     [bos proxy]    [lhr proxy] # Each has local SQLite replica
     |               |              |
     \               |              /
      \              |             /
       \             |            /
        \            v           /
         `--> [Turso/LibSQL] <--´          # Primary database
              Sync every 60s

This architecture provides:

  • Lower latency by serving requests from the nearest region
  • Write-through caching with periodic syncs to the primary database
  • Automatic request routing without client-side config

We can implement that same architecture with a proxy server that acts as an edge replica, and syncs with the primary database.

Before you continue, make sure:

  • You have a Turso Database created, and you have the TURSO_DATABASE_URL and TURSO_AUTH_TOKEN handy.
  • You have a Fly.io account, and the fly CLI installed.
  • You have a Node.js server setup, or you can use the example below.

By the end of this tutorial, your @libsql/client should look and work very similar to what you're used to:

import { createClient } from '@libsql/client/web';

const client = createClient({
  url: 'https://your-app-name.fly.dev',
  authToken: 'supersecrettoken',
});

const result = await client.execute('SELECT * FROM users');

Keep in mind that the proxy and client can only be used in a serverless SQLite context, so you'll want to import the web variant of the libSQL client.

#1. Dockerfile Setup

Create, or modify your Dockerfile for the proxy server:

FROM node:18-slim

# Install SSL certificates
RUN apt-get update -qq && \
    apt-get install -y ca-certificates && \
    update-ca-certificates

WORKDIR /app
COPY package*.json ./
RUN npm install
COPY server.js ./

# Create secure data directory for SQLite
RUN mkdir -p /app/data && \
    chown -R node:node /app/data

USER node

EXPOSE 3000

CMD ["node", "server.js"]

#2. Fly.io Configuration

The fly.toml configures our multi-region deployment:

app = "your-app-name"
primary_region = "lhr"

[build]
dockerfile = "Dockerfile"

[env]
PORT = "3000"

[[mounts]]
source = "libsql_data"
destination = "/app/data"

[[services]]
internal_port = 3000
protocol = "tcp"
auto_stop_machines = false
auto_start_machines = true
min_machines_running = 1

[[services.ports]]
handlers = ["http"]
port = 80
force_https = true

[[services.ports]]
handlers = ["tls", "http"]
port = 443

#3. Proxy Server Implementation

Let's assume you already have a server setup and running, in this example we'll use express, but it could be Hono, Fastify, or any other server you prefer, or even a different language completely.

Begin by installing the @libsql/client dependency:

npm install @libsql/client

Then import createClient, instantiate a new client, and pass it:

  • The path to a local sqlite file
  • The syncUrl to your primary Turso database
  • The authToken for your primary Turso database
  • The syncInterval for how often to sync with the primary database
import { createClient } from '@libsql/client';

const syncInterval = parseInt(process.env.TURSO_SYNC_INTERVAL, 10) || 60;

const client = createClient({
  url: 'file:/app/data/local.db',
  syncUrl: process.env.TURSO_DATABASE_URL,
  authToken: process.env.TURSO_AUTH_TOKEN,
  syncInterval,
});

The syncInterval I have set a fallback to 60 seconds, so it's always kept up to date with the primary database automatically.

Next, create the /v2/pipeline route to be used for receiving requests by @libsql/client — we'll keep it simple and only implement the execute operation for the purposes of this tutorial:

app.post('/v2/pipeline', async (req, res) => {
  try {
    const { requests } = req.body;
    let results = [];

    for (const request of requests) {
      if (request.type === 'execute') {
        const result = await client.execute(request.stmt);
        const rows = result.rows.map((row) => {
          if (Array.isArray(row)) {
            return row.map(formatValue);
          }
          return result.columns.map((col) => formatValue(row[col]));
        });

        results.push({
          type: 'ok',
          response: {
            type: 'execute',
            result: {
              cols: result.columns.map((name) => ({
                name,
                decltype: null,
              })),
              rows,
              affected_row_count: result.rowsAffected || 0,
              last_insert_rowid: result.lastRowId
                ? result.lastRowId.toString()
                : null,
              replication_index: null,
              rows_read: result.rows.length,
              rows_written: result.rowsAffected || 0,
              query_duration_ms: 0,
            },
          },
        });
      } else if (request.type === 'close') {
        results.push({
          type: 'ok',
          response: {
            type: 'close',
          },
        });
      }
    }

    res.json({
      baton: null,
      base_url: null,
      results,
    });
  } catch (error) {
    console.error('Pipeline error:', error);
    res.status(500).json({
      error: {
        message: error.message,
        code: error.code || 'INTERNAL_ERROR',
      },
    });
  }
});

You'll notice we have a formatValue function invoked above, here's a basic implementation:

function formatValue(value) {
  if (value === null) {
    return { type: 'null', value: null };
  }
  if (typeof value === 'number') {
    if (Number.isInteger(value)) {
      return { type: 'integer', value: value.toString() };
    }
    return { type: 'float', value: value.toString() };
  }
  return { type: 'text', value: value.toString() };
}

The server now exposes a /v2/pipeline endpoint that handles database operations, and maintains compatibility SQLite over HTTP with Turso.

Remember, this is a simplified setup, and you may need to add more operations to support your application.

#Protecting Your Proxy

All requests are currently being proxied to the database, and we need to protect this endpoint from unauthorized access.

The @libsql/client sends the authToken in the Authorization header, so we can use this to protect our proxy server.

Instead of configuring full JWT auth, we'll keep it simple and only check if the Authorization header starts with Bearer and the token matches the PROXY_AUTH_TOKEN environment variable.

function verifyClientAuth(req, res, next) {
  const authHeader = req.headers.authorization;

  if (!authHeader?.startsWith('Bearer ')) {
    return res.status(401).json({
      error: { message: 'Missing Authorization header', code: 'UNAUTHORIZED' },
    });
  }

  const clientToken = authHeader.slice(7);

  if (clientToken !== process.env.PROXY_AUTH_TOKEN) {
    return res.status(401).json({
      error: { message: 'Invalid authorization token', code: 'UNAUTHORIZED' },
    });
  }

  next();
}

With the verifyClientAuth function created, we can update the router to use it as middleware:

app.post('/v2/pipeline', verifyClientAuth, async (req, res) => {

#Putting it all together

You should now have something that looks like this:

import express from 'express';
import { createClient } from '@libsql/client';

const app = express();
app.use(express.json());

const syncInterval = parseInt(process.env.TURSO_SYNC_INTERVAL, 10) || 60;

const client = createClient({
  url: 'file:/app/data/local.db',
  syncUrl: process.env.TURSO_DATABASE_URL,
  authToken: process.env.TURSO_AUTH_TOKEN,
  syncInterval,
});

app.post('/v2/pipeline', verifyClientAuth, async (req, res) => {
  try {
    const { requests } = req.body;
    let results = [];

    for (const request of requests) {
      if (request.type === 'execute') {
        const result = await client.execute(request.stmt);
        const rows = result.rows.map((row) => {
          if (Array.isArray(row)) {
            return row.map(formatValue);
          }
          return result.columns.map((col) => formatValue(row[col]));
        });

        results.push({
          type: 'ok',
          response: {
            type: 'execute',
            result: {
              cols: result.columns.map((name) => ({
                name,
                decltype: null,
              })),
              rows,
              affected_row_count: result.rowsAffected || 0,
              last_insert_rowid: result.lastRowId
                ? result.lastRowId.toString()
                : null,
              replication_index: null,
              rows_read: result.rows.length,
              rows_written: result.rowsAffected || 0,
              query_duration_ms: 0,
            },
          },
        });
      } else if (request.type === 'close') {
        results.push({
          type: 'ok',
          response: {
            type: 'close',
          },
        });
      }
    }

    res.json({
      baton: null,
      base_url: null,
      results,
    });
  } catch (error) {
    console.error('Pipeline error:', error);
    res.status(500).json({
      error: {
        message: error.message,
        code: error.code || 'INTERNAL_ERROR',
      },
    });
  }
});

const port = process.env.PORT || 3000;

app.listen(port, () => {
  console.log(`LibSQL proxy server running on port ${port}`);
  console.log(`Region: ${process.env.FLY_REGION}`);
  console.log(`Sync Internal: ${syncInterval}`);
  console.log(`Database path: /app/data/local.db`);
});

function verifyClientAuth(req, res, next) {
  const authHeader = req.headers.authorization;

  if (!authHeader?.startsWith('Bearer ')) {
    return res.status(401).json({
      error: { message: 'Missing Authorization header', code: 'UNAUTHORIZED' },
    });
  }

  const clientToken = authHeader.slice(7);

  if (clientToken !== process.env.PROXY_AUTH_TOKEN) {
    return res.status(401).json({
      error: { message: 'Invalid authorization token', code: 'UNAUTHORIZED' },
    });
  }

  next();
}

function formatValue(value) {
  if (value === null) {
    return { type: 'null', value: null };
  }

  if (typeof value === 'number') {
    if (Number.isInteger(value)) {
      return { type: 'integer', value: value.toString() };
    }

    return { type: 'float', value: value.toString() };
  }

  return { type: 'text', value: value.toString() };
}

You'll notice above when we start listening on the port, we also log the region, sync interval, and database path. This is useful for debugging and ensuring everything is working as expected.

#Deployment and Management

We'll deploy the proxy to Fly.io and scale it across multiple regions to provide low-latency access to users worldwide.

  1. Create regional volumes:
fly volumes create libsql_data --size 10 --region lhr
fly volumes create libsql_data --size 10 --region sin
fly volumes create libsql_data --size 10 --region bos
  1. Set required secrets:
fly secrets set TURSO_DATABASE_URL=libsql://your-database.turso.io
fly secrets set TURSO_AUTH_TOKEN=your-auth-token
fly secrets set PROXY_AUTH_TOKEN=your-chosen-secret
  1. Deploy and scale:
fly deploy
fly scale count 3 --region lhr,sin,bos

You should pick the regions that best serves your users — see the full list.

#Using the Proxy

You can now use the @libsql/client as you did before, but instead of passing it the Turso url and authToken, pass it the proxy URL and auth token:

import { createClient } from '@libsql/client/web';

const client = createClient({
  url: 'https://your-app-name.fly.dev',
  authToken: process.env.PROXY_AUTH_TOKEN,
});

The proxy maintains a local SQLite file that syncs with your primary database every 60 seconds (configurable via TURSO_SYNC_INTERVAL).

When clients make requests, they're automatically routed to the nearest replica through Fly.io's anycast network.

#Going Further

This post has been a brief introduction into the concept of creating your own edge replica on Fly with Node.js and libSQL. You can extend this setup by using another language, framework, and supporting even more features.

Turso Community Contributor dmio_co forked the turso-cdn repository, and extended it to support more operations — check it out.

As always, join us on Discord to share your feedback and ideas for what to build next!

scarf