Skip to content

Cloud SQL Proxy Lifecycle

When to use

Use this pattern when a Node.js application needs to connect to a Cloud SQL database through the Cloud SQL Proxy during local development. The proxy must start before the application can connect, and its lifecycle — setup, startup detection, and cleanup — needs careful management.

Prefer ports over sockets for local development. Unix sockets have a ~100-character path limit that causes silent failures with long instance names. TCP ports avoid this entirely.

The pattern

Wrap the Cloud SQL Proxy binary in a manager that handles three concerns:

  1. Resource setup — create the socket directory (or allocate a port) and clean stale resources
  2. Startup coordination — monitor proxy output to detect success or failure, with a timeout
  3. Cleanup on exit — remove sockets and kill the proxy when the parent process exits

Starting the proxy

Create a CLI entry point that calls startProxy() with your instance configuration:

javascript
#!/usr/bin/env node
import { program } from "commander";
import config from "config";

program.name("proxy");
program.description("Start local database proxy");

program.action(async () => {
  const instances = config.get("db.proxy.instances");
  try {
    await startProxy(instances, "/tmp/cloudsql");
    console.log("Proxy is ready.");
  } catch (error) {
    console.error(error.message);
    process.exit(1);
  }
});

program.parse();

Store the instance list in a config file so each developer can override it locally:

json
{
  "db": {
    "proxy": {
      "instances": [
        "my-project:us-east1:my-db"
      ]
    }
  }
}

Defining the socket collection manager

javascript
import fs from "node:fs";
import { cleanupOnExit, sh } from "./process.js";

class DBSocketCollection {
  socketRootPath = "/tmp/cloudsql";
  instanceNames = [];
  
  constructor(instanceNames) {
    this.instanceNames = instanceNames;
  }
  
  init() {
    this.ensureSocketRootPath();
    this.clearSockets();
    cleanupOnExit(() => this.clearSockets());
    return this;
  }
  
  ensureSocketRootPath() {
    const { socketRootPath } = this;
    sh`mkdir -p ${socketRootPath}`.runSync();
    
    const READ_WRITE =
      fs.constants.F_OK | fs.constants.R_OK | fs.constants.W_OK;
    try {
      fs.accessSync(socketRootPath, READ_WRITE);
    } catch {
      throw new Error(
        `Socket directory '${socketRootPath}' is not writable.\n`
        + `Run:\n`
        + `  sudo mkdir '${socketRootPath}'\n`
        + `  sudo chown -R $(whoami) '${socketRootPath}'\n`
        + `  sudo chmod u+w '${socketRootPath}'`
      );
    }
  }
  
  get socketPaths() {
    return this.instanceNames.map(
      name => `${this.socketRootPath}/${name}`
    );
  }
  
  clearSockets() {
    for (const p of this.socketPaths) {
      if (fs.existsSync(p)) fs.unlinkSync(p);
    }
  }
}

Defining startup coordination

Monitor the proxy's stdout for success or failure messages. Use Promise.withResolvers() to create a promise that resolves when the proxy is ready or rejects on error or timeout:

javascript
const SUCCESS_MSG =
  "The proxy has started successfully "
  + "and is ready for new connections!";
const FAILURE_MSG =
  "The proxy has encountered a terminal error";

async function startProxy(instances, socketRootPath) {
  const sockets =
    new DBSocketCollection(instances).init();
  
  const startup = Promise.withResolvers();
  let finalized = false;
  
  const subprocess = sh`
    cloud-sql-proxy
    --unix-socket=${socketRootPath}
    --credentials-file=./cloud-sql.private.key.json
    ${instances}
  `.run();
  
  subprocess.finally(() => sockets.clearSockets());
  
  // Timeout if proxy doesn't start in time
  const timeout = setTimeout(() => {
    if (!finalized) {
      finalized = true;
      startup.reject(
        new Error("Proxy failed to start within 5 seconds")
      );
    }
  }, 5_000);
  
  subprocess.output.on("data", (raw) => {
    const data = String(raw);
    if (!finalized && data.includes(SUCCESS_MSG)) {
      finalized = true;
      clearTimeout(timeout);
      startup.resolve();
    }
    if (!finalized && data.includes(FAILURE_MSG)) {
      finalized = true;
      clearTimeout(timeout);
      startup.reject(new Error(data));
    }
  });
  
  await startup.promise;
  return subprocess;
}

Use --port instead of --unix-socket to avoid the socket path length limit:

javascript
const subprocess = sh`
  cloud-sql-proxy
  --port=5432
  --credentials-file=./cloud-sql.private.key.json
  ${instances}
`.run();

Then connect your application to localhost:5432 instead of a socket path.

Trade-offs

  • Sockets vs. ports — sockets avoid port conflicts but have a path length limit (~100 chars). Ports are simpler and more reliable for local development.
  • Output monitoring — parsing stdout for known strings is fragile if the proxy changes its output format. Pin the proxy version to reduce surprises.
  • Timeout duration — too short and the proxy fails on slow networks; too long and developers wait unnecessarily. Five seconds is a reasonable default.
  • cleanupOnExit — the cleanup handler must run on SIGINT, SIGTERM, and uncaughtException to avoid leaving stale sockets or zombie processes.
  • Promise.withResolvers() — requires Node.js 22+ or a polyfill like @ungap/with-resolvers. The ergonomic gain over new Promise() is worth the dependency for startup coordination where resolve/reject are called from an event listener outside the constructor callback.