Self-Host Proxy Server

Build your own proxy server for complete control over your LunoDB Mobile database connections

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 ServerMongoDBRedis

Direct 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

  1. Connection Request - Mobile app sends database credentials to your proxy
  2. Session Creation - Proxy establishes connection and returns a session ID
  3. Query Execution - App sends queries using the session ID
  4. Results Return - Proxy executes queries and returns results to the app
  5. 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 values

Error 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)

MySQLmysql25.7+
PostgreSQLpg10+
MariaDBmysql210.0+
MongoDBmongodb4.0+

Direct Connection Databases

These databases connect directly from the mobile app without requiring a proxy server:

TursoHTTPS API

Edge SQLite database with libSQL. Connects via /v2/pipeline HTTP endpoint.

SQLiteLocal File

Local 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

POST/proxy/connections/test

Test database connection without creating a session.

Request Body:
{
  "type": "mysql",
  "host": "db.example.com",
  "port": 3306,
  "username": "user",
  "password": "pass",
  "database": "mydb"
}
Response:
{
  "success": true,
  "version": "8.0.32",
  "message": "Connection successful"
}
POST/proxy/connections

Create persistent session for database operations.

Request Body:
{
  "type": "mysql",
  "host": "db.example.com",
  "port": 3306,
  "username": "user",
  "password": "pass",
  "database": "mydb",
  "safeMode": false  // Optional: blocks DROP, TRUNCATE, DELETE when true
}
Response:
{
  "sessionId": "550e8400-e29b-41d4-a716-446655440000",
  "version": "8.0.32",
  "expiresAt": "2026-01-22T12:30:00Z"
}
DELETE/proxy/connections/{sessionId}

Close session and release database connection.

Schema Introspection

GET/proxy/schema/{sessionId}/databasesList all databases
GET/proxy/schema/{sessionId}/tables?database={db}List tables in database
GET/proxy/schema/{sessionId}/tables/{table}/structureGet column definitions
GET/proxy/schema/{sessionId}/tables/{table}/indexesGet table indexes
GET/proxy/schema/{sessionId}/tables/{table}/foreign-keysGet foreign key relationships

Query Execution

POST/proxy/query/{sessionId}/execute

Execute SQL query and return results.

Request Body:
{
  "query": "SELECT * FROM users WHERE status = ?",
  "params": ["active"],
  "database": "mydb"
}
Response:
{
  "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

CodeHTTPDescription
SESSION_NOT_FOUND404Session expired or not found
SESSION_EXPIRED410Session has expired
CONNECTION_FAILED502Cannot connect to database
CONNECTION_TIMEOUT504Connection timed out
SSH_AUTH_FAILED502SSH tunnel authentication failed
SSH_CONNECTION_FAILED502Cannot establish SSH tunnel
QUERY_ERROR400SQL syntax or execution error
QUERY_TIMEOUT504Query execution timed out
OPERATION_BLOCKED403Operation blocked by Safe Mode
OPERATION_NOT_ALLOWED403Operation requires desktop app
UNAUTHORIZED401Authentication required
RATE_LIMITED429Too 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 termination

Never 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 GLOBAL

Safe 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 FROM

Always Allowed

These operations are always available in the mobile app, regardless of Safe Mode settings.

SELECTINSERTUPDATECREATE TABLEALTER TABLECREATE INDEXSHOWDESCRIBEEXPLAIN

Tip: 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

  1. Open LunoDB Mobile
  2. Go to SettingsConnection Settings
  3. Scroll to Proxy Server section
  4. Enter your proxy URL (e.g., https://proxy.yourdomain.com/api)
  5. 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.

  1. Open a connection in LunoDB Mobile
  2. Tap the connection settings (gear icon)
  3. Toggle Safe Mode on
  4. 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.

Related Resources