Appearance
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:
- Resource setup — create the socket directory (or allocate a port) and clean stale resources
- Startup coordination — monitor proxy output to detect success or failure, with a timeout
- 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;
}Port-based alternative (recommended for development)
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 onSIGINT,SIGTERM, anduncaughtExceptionto avoid leaving stale sockets or zombie processes.Promise.withResolvers()— requires Node.js 22+ or a polyfill like@ungap/with-resolvers. The ergonomic gain overnew Promise()is worth the dependency for startup coordination where resolve/reject are called from an event listener outside the constructor callback.