Back to Blog
Tutorial12 min read2026-08-01

Wiring an Express API to a Managed MySQL Database

Connect Express to a managed MySQL instance with automatic DATABASE_URL injection, connection pooling, and proper error handling for production traffic.

Ajay Kumar
Ajay Kumar
Founder & DevOps, PandaStack

An Express API without a database is just a router that returns JSON. The moment you need to persist user data, inventory, or sessions, you need MySQL or PostgreSQL. PandaStack provisions a managed instance, injects DATABASE_URL as an environment variable, and handles backups automatically — but your application code still has to connect correctly or every request will fail with ECONNREFUSED.

The most common mistake is hardcoding localhost:3306 in your connection string. That works in development, but production pods don't run MySQL locally. The database is a separate service with its own hostname, and the only way your app learns it is through process.env.DATABASE_URL.

Create the database first

Before deploying the API, provision a MySQL instance. Use the REST API:

curl -X POST https://api.pandastack.io/v1/databases \
  -H "Authorization: Bearer $PANDASTACK_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "orders-db",
    "engine": "mysql",
    "version": "8.0"
  }'

The response includes a databaseId. Write it down — you'll link it to your API in the next step. The database takes about two minutes to provision. Check its status:

curl https://api.pandastack.io/v1/databases/<databaseId> \
  -H "Authorization: Bearer $PANDASTACK_TOKEN"

When "status": "RUNNING", it's ready. The API won't return the connection string directly because it's a secret, but PandaStack will inject it when you link the database to a project.

Set up the Express app

Your server.js should read DATABASE_URL from the environment and create a connection pool:

const express = require('express');
const mysql = require('mysql2/promise');

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

const pool = mysql.createPool({
  uri: process.env.DATABASE_URL,
  waitForConnections: true,
  connectionLimit: 10,
  queueLimit: 0
});

app.get('/orders', async (req, res) => {
  try {
    const [rows] = await pool.query('SELECT * FROM orders');
    res.json({ orders: rows });
  } catch (err) {
    console.error('Database error:', err);
    res.status(500).json({ error: 'Database unavailable' });
  }
});

const PORT = process.env.PORT || 3000;
app.listen(PORT, '0.0.0.0', () => {
  console.log(`Server listening on ${PORT}`);
});

Two critical details: bind to 0.0.0.0 (not localhost, which breaks container networking), and use a connection pool (not mysql.createConnection(), which will exhaust sockets under load).

Deploy and link the database

Create the project via the API and link the database in one request:

{
  "slug": "container",
  "name": "orders-api",
  "repositoryName": "acme/orders-api",
  "branch": "main",
  "autoDeploy": true,
  "databaseId": "<your-database-id>",
  "env": [
    { "name": "NODE_ENV", "value": "production" }
  ]
}

PandaStack detects the package.json start script, builds a container, and injects DATABASE_URL automatically. You don't set it manually — linking the database does it for you.

The build log shows:

[builder] Detected Node.js app
[builder] Running npm install
[builder] Build complete
[deployer] Injecting DATABASE_URL
[deployer] Starting container on port 3000

Your API is now live at https://.pandastack.app.

Verify the connection

Test the /orders endpoint:

curl https://orders-api.pandastack.app/orders

If the table doesn't exist yet, you'll see {"error": "Database unavailable"} because the query fails. Create the schema before deploying, or add a migration step to your startup script.

Run migrations on deploy

Production databases need schema versioning. Add a migrate script to package.json:

{
  "scripts": {
    "start": "node server.js",
    "migrate": "node migrations/run.js"
  }
}

Your migrations/run.js might use a library like db-migrate or plain SQL:

const mysql = require('mysql2/promise');

async function migrate() {
  const connection = await mysql.createConnection(process.env.DATABASE_URL);
  await connection.query(`
    CREATE TABLE IF NOT EXISTS orders (
      id INT AUTO_INCREMENT PRIMARY KEY,
      product VARCHAR(255),
      quantity INT,
      created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
    )
  `);
  await connection.end();
  console.log('Migration complete');
}

migrate().catch(console.error);

Then override the start command in your deploy configuration:

{
  "startCommand": "npm run migrate && npm start"
}

Now every deploy runs migrations before starting the server. If a migration fails, the deploy fails, and the old version keeps running (zero-downtime).

Connection pooling explained

Free-tier databases limit connections to 50. A naive createConnection() per request will hit that limit at 50 concurrent users, then every subsequent request hangs. A pool reuses connections:

const pool = mysql.createPool({
  uri: process.env.DATABASE_URL,
  connectionLimit: 10
});

The pool maintains 10 persistent connections. Requests queue if all 10 are busy, but they don't fail. For higher traffic, increase connectionLimit (Pro tier allows 300 connections).

Deploy with the CLI

If the API workflow is repetitive, script it:

panda login
panda projects create \
  --name orders-api \
  --repo acme/orders-api \
  --branch main \
  --type container \
  --auto-deploy

Then link the database from the dashboard (Projects → Settings → Database). The CLI doesn't support --database-id yet, so that step is manual.

Debugging connection failures

getaddrinfo ENOTFOUND: DATABASE_URL is missing or malformed. Check the environment variables in the dashboard.

ER_CON_COUNT_ERROR: Too many connections: You're not using a pool, or your connection limit is too low. Free tier caps at 50 connections; upgrade to Pro for 300.

connect ECONNREFUSED: You're connecting to localhost instead of the injected URL. Remove any hardcoded host.

What about PostgreSQL?

The process is identical. Change "engine": "mysql" to "engine": "postgresql" in the database creation payload, and use pg instead of mysql2 in your Node.js code:

const { Pool } = require('pg');
const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
  ssl: { rejectUnauthorized: false }
});

PandaStack enforces TLS for database connections, so the ssl option is required.

References

  • [Node.js MySQL2 documentation](https://github.com/sidorares/node-mysql2)
  • [PandaStack database API](https://docs.pandastack.io/api/databases)
  • [Connection pooling best practices](https://expressjs.com/en/advanced/best-practice-performance.html)

Ready to deploy?

Start free on PandaStack.

Start free on PandaStack

More in Tutorial

Browse all Tutorial articles →

See also