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.
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:
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:
TURSO_DATABASE_URL
and TURSO_AUTH_TOKEN
handy.fly
CLI installed.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.
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"]
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
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:
syncUrl
to your primary Turso databaseauthToken
for your primary Turso databasesyncInterval
for how often to sync with the primary databaseimport { 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.
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) => {
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.
We'll deploy the proxy to Fly.io and scale it across multiple regions to provide low-latency access to users worldwide.
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
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
fly deploy
fly scale count 3 --region lhr,sin,bos
You should pick the regions that best serves your users — see the full list.
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.
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!