https://turso.tech/blog/do-it-yourself-database-cdn-with-embedded-replicas Turso LoginOpen main menu Product DocsCustomersPricingBlog Schedule a callFollow us on XJoin us on Discord LoginSign Up Feb 12, 2025 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 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 2. 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 3. 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! Share article Twitter / XHacker NewsEmailLinkedInCopy link Turso: SQLite for Production Get StartedStar Our Repo Developers Quickstart SDK Reference CLI Reference API Reference Examples API Status Features Embedded Replicas Database Branching Backups & Recovery Platform API Product For AI For Healthcare For Platforms Multi-Tenancy libSQL Pricing Company About Brand Contact Us Support Trust Blog Careers RSSYouTubeDiscordXLinkedinGitHub (c) Turso 2025 * Terms of Use * Privacy Policy scarf