Overview
LunoDB Mobile connects to remote databases through a proxy server. By default, connections route through LunoDB Cloud, our secure connection gateway. However, for users who want complete control over their data flow, you can build and host your own proxy server.
Direct Connection Databases
Turso and local SQLite databases connect directly from the mobile app without needing a proxy server. Turso uses HTTPS API calls, and SQLite files are stored locally on your device.
Why Self-Host?
- Complete Control - All database traffic routes through your infrastructure
- Compliance - Meet internal security policies or regulatory requirements
- Network Access - Connect to databases inside private networks without exposing them
- Peace of Mind - Know exactly how your connections are handled
Advanced Setup Required
Self-hosting requires Node.js development experience and server administration knowledge. For most users, LunoDB Cloud provides secure, encrypted connections with zero setup.
Architecture
The proxy server acts as an intermediary between the LunoDB Mobile app and your databases. Since iOS and Android apps can't create direct TCP socket connections to traditional databases (MySQL, PostgreSQL, MariaDB) due to platform restrictions, the proxy bridges this gap securely.
Proxy Required
These databases use TCP socket connections that require a proxy server:
MySQLPostgreSQLMariaDBSQL ServerMongoDBRedisDirect Connection
These connect directly via HTTPS or local storage (no proxy needed):
TursoSQLite┌─────────────────────┐ HTTPS ┌──────────────────────┐
│ LunoDB Mobile │◄──────────────────────►│ Your Proxy Server │
│ (iOS/Android) │ REST API │ (Node.js) │
└─────────────────────┘ └──────────┬───────────┘
│
┌──────────────────────┼──────────────────────┐
│ │ │
┌─────▼─────┐ ┌─────▼─────┐ ┌─────▼─────┐
│ MySQL │ │ PostgreSQL│ │ MariaDB │
│ Server │ │ Server │ │ Server │
└───────────┘ └───────────┘ └───────────┘How It Works
- Connection Request - Mobile app sends database credentials to your proxy
- Session Creation - Proxy establishes connection and returns a session ID
- Query Execution - App sends queries using the session ID
- Results Return - Proxy executes queries and returns results to the app
- Session Cleanup - Sessions expire after inactivity or explicit disconnect
Session-Based Design
The proxy uses session tokens to maintain database connections. This approach ensures credentials are only transmitted once during connection setup, not with every query. Sessions automatically expire after 30 minutes of inactivity.
SSH Tunneling
Many databases are protected behind firewalls and only accessible through a bastion host or jump server. SSH tunneling allows your proxy to securely connect to these databases by creating an encrypted tunnel through the SSH server.
When to Use SSH Tunneling
- Private Networks - Database is on a private subnet with no public IP
- Bastion Hosts - Access requires jumping through an SSH gateway
- Cloud Security - AWS RDS, GCP Cloud SQL, or Azure databases in private VPCs
- Firewall Rules - Database only accepts connections from specific IPs
SSH Tunnel Architecture
┌─────────────────────┐ HTTPS ┌──────────────────────┐
│ LunoDB Mobile │◄──────────────────────►│ Your Proxy Server │
│ (iOS/Android) │ REST API │ (Node.js) │
└─────────────────────┘ └──────────┬───────────┘
│
SSH Tunnel │ (Encrypted)
│
┌──────────▼───────────┐
│ Bastion Host │
│ (SSH Server) │
└──────────┬───────────┘
│
Private │ Network
│
┌──────────▼───────────┐
│ Database Server │
│ (MySQL/PostgreSQL) │
└──────────────────────┘SSH Configuration Options
Include the ssh object in your connection request to enable tunneling:
{
"type": "mysql",
"host": "10.0.1.50", // Private IP of database
"port": 3306,
"username": "db_user",
"password": "db_password",
"database": "mydb",
"ssh": {
"enabled": true,
"host": "bastion.example.com", // SSH server hostname
"port": 22, // SSH port (default: 22)
"username": "ssh_user", // SSH username
"authType": "key", // "password" or "key"
"privateKey": "-----BEGIN RSA PRIVATE KEY-----\n...",
"passphrase": "optional_key_passphrase"
}
}Authentication Methods
SSH Key (Recommended)
More secure than passwords. Supports RSA, ECDSA, and Ed25519 keys.
"ssh": {
"enabled": true,
"host": "bastion.example.com",
"port": 22,
"username": "ubuntu",
"authType": "key",
"privateKey": "-----BEGIN RSA PRIVATE KEY-----...",
"passphrase": "optional"
}Password Authentication
Simpler setup but less secure. Use only when key auth isn't available.
"ssh": {
"enabled": true,
"host": "bastion.example.com",
"port": 22,
"username": "ubuntu",
"authType": "password",
"password": "ssh_password"
}Implementation with ssh2
Add the ssh2 package to your proxy server:
const { Client } = require('ssh2');
const mysql = require('mysql2/promise');
const net = require('net');
async function createSSHTunnel(sshConfig, dbConfig) {
return new Promise((resolve, reject) => {
const ssh = new Client();
ssh.on('ready', () => {
// Forward connection to database through tunnel
ssh.forwardOut(
'127.0.0.1', // Source address
0, // Source port (auto-assign)
dbConfig.host,
dbConfig.port,
async (err, stream) => {
if (err) {
ssh.end();
return reject(err);
}
// Create database connection through the tunnel
const connection = await mysql.createConnection({
...dbConfig,
stream: stream, // Use SSH stream instead of TCP
});
resolve({ connection, ssh });
}
);
});
ssh.on('error', reject);
// Connect to SSH server
const connectConfig = {
host: sshConfig.host,
port: sshConfig.port || 22,
username: sshConfig.username,
};
if (sshConfig.authType === 'key') {
connectConfig.privateKey = sshConfig.privateKey;
if (sshConfig.passphrase) {
connectConfig.passphrase = sshConfig.passphrase;
}
} else {
connectConfig.password = sshConfig.password;
}
ssh.connect(connectConfig);
});
}
// Usage in your connection endpoint
app.post('/proxy/connections', async (req, res) => {
const { ssh, ...dbConfig } = req.body;
try {
let connection, sshClient;
if (ssh?.enabled) {
const tunnel = await createSSHTunnel(ssh, dbConfig);
connection = tunnel.connection;
sshClient = tunnel.ssh;
} else {
connection = await mysql.createConnection(dbConfig);
}
const sessionId = uuidv4();
sessions.set(sessionId, {
connection,
sshClient, // Store SSH client for cleanup
lastActivity: Date.now()
});
res.json({ sessionId, /* ... */ });
} catch (error) {
if (error.message.includes('authentication')) {
res.status(502).json({ error: 'SSH_AUTH_FAILED', message: error.message });
} else {
res.status(502).json({ error: 'SSH_CONNECTION_FAILED', message: error.message });
}
}
});Clean Up SSH Connections
When closing sessions, remember to close both the database connection and the SSH client. Failing to close the SSH tunnel will leave orphaned connections on your bastion host.
Alternative: CLI-Based SSH Tunneling
Instead of port forwarding, you can run queries by invoking the mysql or psql CLI directly on the SSH server. This approach is simpler and works well for stateless PHP/Laravel proxies.
Response Format Requirements
The mobile app expects rows as associative arrays (objects with column names as keys), not indexed arrays. For SSH tunneled connections, you must parse the CLI output and map values to their column names. The fields array should contain column metadata with at least the name property.
Secure Password Handling
Use the MYSQL_PWD environment variable for MySQL/MariaDB and PGPASSWORD for PostgreSQL instead of passing passwords on the command line. This avoids the "Using a password on the command line interface can be insecure" warning and keeps the password out of process listings.
CLI Command Format
For MySQL/MariaDB, use batch mode (-B) for tab-separated output. Include column headers in the output so you can build associative arrays:
# MySQL - with headers (for SELECT queries)
MYSQL_PWD='password' mysql -h host -P 3306 -u user database -B -e 'SELECT * FROM users'
# MySQL - without headers (for internal queries like SHOW DATABASES)
MYSQL_PWD='password' mysql -h host -P 3306 -u user database -N -B -e 'SHOW DATABASES'
# PostgreSQL - with headers
PGPASSWORD='password' psql -h host -p 5432 -U user database -A -F $'\t' -c 'SELECT * FROM users'
# PostgreSQL - without headers (tuples only)
PGPASSWORD='password' psql -h host -p 5432 -U user database -t -A -F $'\t' -c 'SELECT 1'Parsing CLI Output
The first line of output contains column headers. Parse subsequent lines and map values to column names:
// Example CLI output (tab-separated):
// id name status
// 1 John active
// 2 Jane \N
// Should be parsed to:
{
"rows": [
{ "id": "1", "name": "John", "status": "active" },
{ "id": "2", "name": "Jane", "status": null }
],
"fields": [
{ "name": "id", "type": "string" },
{ "name": "name", "type": "string" },
{ "name": "status", "type": "string" }
]
}
// Note: \N in MySQL output represents NULL valuesError Detection
When executing queries via CLI, you need to detect database errors in the output. Use regex patterns instead of simple string matching to avoid false positives from table names containing words like "error" (e.g., price_errors, audit_errors).
// ❌ BAD: Simple string matching causes false positives
// This would match table names like "price_errors"
if (output.toLowerCase().includes('error')) {
throw new Error('Query failed');
}
// ✅ GOOD: Use specific regex patterns
const errorPatterns = [
/^ERROR \d+/m, // MySQL: "ERROR 1045 (28000)..."
/^error:/im, // Error prefix at line start only
/Access denied for user/i, // MySQL access denied
/Connection refused/i, // Connection error
/Unknown database/i, // MySQL unknown database
/command not found/i, // CLI not installed
/^FATAL:/m, // PostgreSQL fatal errors
/authentication failed/i, // Auth failure
/Can't connect to/i, // MySQL connection error
];
for (const pattern of errorPatterns) {
if (pattern.test(output)) {
throw new Error(`Query error: ${output}`);
}
}Avoid False Positives
Database schemas often contain tables with names like error_logs, sync_errors, or validation_errors. Using simple string matching like includes('error') will incorrectly flag these as query failures. Always use regex patterns that match the specific format of database error messages.
Requirements
Server Requirements
Runtime
- Node.js 18+ (LTS recommended)
- 1GB RAM minimum
- Linux, macOS, or Windows
Network
- Public IP or domain name
- HTTPS with valid TLS certificate
- Access to target databases
Required Dependencies
{
"dependencies": {
"express": "^4.18.0",
"mysql2": "^3.6.0",
"pg": "^8.11.0",
"uuid": "^9.0.0",
"cors": "^2.8.5",
"ssh2": "^1.15.0"
}
}Supported Databases (Proxy)
mysql25.7+pg10+mysql210.0+mongodb4.0+Direct Connection Databases
These databases connect directly from the mobile app without requiring a proxy server:
HTTPS APIEdge SQLite database with libSQL. Connects via /v2/pipeline HTTP endpoint.
Local FileLocal database files stored on your device. No network connection required.
API Specification
Your proxy server must implement these REST API endpoints. All requests and responses use JSON format.
Connection Management
/proxy/connections/testTest database connection without creating a session.
{
"type": "mysql",
"host": "db.example.com",
"port": 3306,
"username": "user",
"password": "pass",
"database": "mydb"
}{
"success": true,
"version": "8.0.32",
"message": "Connection successful"
}/proxy/connectionsCreate persistent session for database operations.
{
"type": "mysql",
"host": "db.example.com",
"port": 3306,
"username": "user",
"password": "pass",
"database": "mydb",
"safeMode": false // Optional: blocks DROP, TRUNCATE, DELETE when true
}{
"sessionId": "550e8400-e29b-41d4-a716-446655440000",
"version": "8.0.32",
"expiresAt": "2026-01-22T12:30:00Z"
}/proxy/connections/{sessionId}Close session and release database connection.
Schema Introspection
/proxy/schema/{sessionId}/databasesList all databases/proxy/schema/{sessionId}/tables?database={db}List tables in database/proxy/schema/{sessionId}/tables/{table}/structureGet column definitions/proxy/schema/{sessionId}/tables/{table}/indexesGet table indexes/proxy/schema/{sessionId}/tables/{table}/foreign-keysGet foreign key relationshipsQuery Execution
/proxy/query/{sessionId}/executeExecute SQL query and return results.
{
"query": "SELECT * FROM users WHERE status = ?",
"params": ["active"],
"database": "mydb"
}{
"rows": [
{ "id": 1, "name": "John", "status": "active" },
{ "id": 2, "name": "Jane", "status": "active" }
],
"fields": [
{ "name": "id", "type": "INT" },
{ "name": "name", "type": "VARCHAR" },
{ "name": "status", "type": "VARCHAR" }
],
"affectedRows": 0,
"executionTime": 12
}Error Codes
| Code | HTTP | Description |
|---|---|---|
SESSION_NOT_FOUND | 404 | Session expired or not found |
SESSION_EXPIRED | 410 | Session has expired |
CONNECTION_FAILED | 502 | Cannot connect to database |
CONNECTION_TIMEOUT | 504 | Connection timed out |
SSH_AUTH_FAILED | 502 | SSH tunnel authentication failed |
SSH_CONNECTION_FAILED | 502 | Cannot establish SSH tunnel |
QUERY_ERROR | 400 | SQL syntax or execution error |
QUERY_TIMEOUT | 504 | Query execution timed out |
OPERATION_BLOCKED | 403 | Operation blocked by Safe Mode |
OPERATION_NOT_ALLOWED | 403 | Operation requires desktop app |
UNAUTHORIZED | 401 | Authentication required |
RATE_LIMITED | 429 | Too many requests |
Implementation
Basic Server Structure
Here's a minimal Express.js implementation to get started:
const express = require('express');
const mysql = require('mysql2/promise');
const { v4: uuidv4 } = require('uuid');
const cors = require('cors');
const app = express();
app.use(cors());
app.use(express.json());
// In-memory session store (use Redis in production)
const sessions = new Map();
const SESSION_TIMEOUT = 30 * 60 * 1000; // 30 minutes
// Create session
app.post('/proxy/connections', async (req, res) => {
try {
const { type, host, port, username, password, database } = req.body;
const connection = await mysql.createConnection({
host, port, user: username, password, database
});
const sessionId = uuidv4();
sessions.set(sessionId, {
connection,
config: req.body,
lastActivity: Date.now()
});
// Get version
const [rows] = await connection.query('SELECT VERSION() as version');
res.json({
sessionId,
version: rows[0].version,
expiresAt: new Date(Date.now() + SESSION_TIMEOUT).toISOString()
});
} catch (error) {
res.status(502).json({
error: 'CONNECTION_FAILED',
message: error.message
});
}
});
// Execute query
app.post('/proxy/query/:sessionId/execute', async (req, res) => {
const session = sessions.get(req.params.sessionId);
if (!session) {
return res.status(404).json({
error: 'SESSION_NOT_FOUND',
message: 'Session expired or not found'
});
}
session.lastActivity = Date.now();
try {
const { query, params, database } = req.body;
if (database) {
await session.connection.changeUser({ database });
}
const startTime = Date.now();
const [rows, fields] = await session.connection.query(query, params);
res.json({
rows: Array.isArray(rows) ? rows : [],
fields: fields?.map(f => ({ name: f.name, type: f.type })) || [],
affectedRows: rows.affectedRows || 0,
executionTime: Date.now() - startTime
});
} catch (error) {
res.status(400).json({
error: 'QUERY_ERROR',
message: error.message
});
}
});
// Disconnect
app.delete('/proxy/connections/:sessionId', async (req, res) => {
const session = sessions.get(req.params.sessionId);
if (session) {
await session.connection.end();
sessions.delete(req.params.sessionId);
}
res.json({ success: true });
});
// Session cleanup
setInterval(() => {
const now = Date.now();
for (const [id, session] of sessions) {
if (now - session.lastActivity > SESSION_TIMEOUT) {
session.connection.end();
sessions.delete(id);
}
}
}, 60000);
app.listen(3000, () => console.log('Proxy running on port 3000'));Production Recommendations
Use Redis for Sessions
Replace in-memory Map with Redis for multi-instance support
Connection Pooling
Implement connection pools instead of single connections per session
Rate Limiting
Add rate limiting middleware to prevent abuse
Request Logging
Log all queries for audit trails (without sensitive parameters)
Health Checks
Add /health endpoint for load balancer monitoring
Security
Security is Critical
Your proxy server handles database credentials. Follow these security practices carefully.
Required Security Measures
HTTPS Only
Always use TLS 1.2+ encryption. Never accept HTTP connections.
# Use Let's Encrypt for free TLS certificates
certbot certonly --standalone -d proxy.yourdomain.com
# Or use a reverse proxy like nginx with TLS terminationNever Store Credentials
Database credentials should only exist in memory during the session. Never log or persist them. Store only the session ID reference to the connection.
Session Expiration
Sessions should expire after 30 minutes of inactivity. Clean up expired sessions promptly to release database connections.
Network Isolation
Deploy the proxy in the same network as your databases. Use firewall rules to restrict which databases the proxy can access. Consider VPN or private networking.
SSH Tunnel Security
Prefer SSH Keys Over Passwords
SSH key authentication is significantly more secure than passwords. Use Ed25519 or RSA 4096-bit keys. Never use password authentication on production systems.
Protect Private Keys
Never log or store SSH private keys in plain text. Keys should only exist in memory during the connection. Consider using a secrets manager like HashiCorp Vault.
Restrict Bastion Host Access
Configure your bastion host to only allow connections from your proxy server's IP. Use fail2ban to block brute force attempts. Disable password authentication on the SSH server.
Close Idle Tunnels
SSH connections consume resources on the bastion host. Ensure your session cleanup also closes SSH tunnels. Monitor for orphaned connections.
Safe Mode & Query Restrictions
The proxy includes built-in query restrictions to protect against accidental data loss. Some operations are always blocked in the mobile app, while others can be controlled via Safe Mode.
Always Blocked (Use Desktop App)
These server-level operations are never allowed in the mobile app for security reasons. Use the LunoDB desktop app for full access.
GRANTREVOKECREATE USERDROP USERKILLSHUTDOWNFLUSHRESET MASTER/SLAVELOAD DATAINTO OUTFILESET GLOBALSafe Mode (Configurable)
When safeMode: true is set during connection, these data-modifying operations are blocked. Disable Safe Mode to allow them.
DROP TABLEDROP DATABASETRUNCATEALTER DATABASEDELETE FROMAlways Allowed
These operations are always available in the mobile app, regardless of Safe Mode settings.
SELECTINSERTUPDATECREATE TABLEALTER TABLECREATE INDEXSHOWDESCRIBEEXPLAINTip: Safe Mode is off by default. Enable it for shared or production databases where you want an extra layer of protection against accidental data loss.
Optional: API Key Authentication
Add an additional layer of security by requiring an API key:
// API Key middleware
const apiKeyAuth = (req, res, next) => {
const apiKey = req.headers['x-api-key'];
if (!apiKey || apiKey !== process.env.PROXY_API_KEY) {
return res.status(401).json({
error: 'UNAUTHORIZED',
message: 'Invalid or missing API key'
});
}
next();
};
app.use('/proxy', apiKeyAuth);Mobile Configuration
Once your proxy server is running, configure LunoDB Mobile to use it.
Configure in LunoDB Mobile
- Open LunoDB Mobile
- Go to Settings → Connection Settings
- Scroll to Proxy Server section
- Enter your proxy URL (e.g.,
https://proxy.yourdomain.com/api) - Test a connection to verify everything works
Enable Safe Mode
Safe Mode adds protection against accidental data loss by blocking DROP, TRUNCATE, and DELETE operations.
- Open a connection in LunoDB Mobile
- Tap the connection settings (gear icon)
- Toggle Safe Mode on
- Reconnect for changes to take effect
Note: Safe Mode is a per-connection setting. You can have some connections with Safe Mode enabled and others without, depending on your needs.
Tip: If you added API key authentication, you'll need to modify the mobile app's proxy client or use a custom header injection. Contact support for enterprise configurations.
Reset to LunoDB Cloud
To switch back to the default LunoDB Cloud proxy, tap Reset in the Connection Settings screen. This sets the proxy URL back to https://lunodb.app/api.