Give a VM access to Neon without putting the database password inside it. Your trusted controller creates a Postgres TLS rule with the real username, password, and database. The VM connects with placeholders; the Freestyle edge authenticates to Neon on its behalf.
VM with placeholder credentials → verified TLS → Freestyle edge → verified TLS → Neon
real credentials injected here
Prepare A Neon Role
Use a dedicated database or development branch for this example. In the Neon Console, select that branch and database, then open the SQL Editor as its owner. Create a restricted reader role and a small table:
CREATE ROLE freestyle_reader LOGIN PASSWORD 'replace-with-a-generated-password'
NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION;
CREATE SCHEMA guide;
CREATE TABLE guide.messages (id integer PRIMARY KEY, message text NOT NULL);
INSERT INTO guide.messages VALUES (1, 'Hello from Neon');
-- Replace neondb if your database has a different name.
GRANT CONNECT ON DATABASE neondb TO freestyle_reader;
GRANT USAGE ON SCHEMA guide TO freestyle_reader;
GRANT SELECT ON guide.messages TO freestyle_reader;
Create the role with SQL: roles created through Neon’s Console role-management UI, CLI, or API receive neon_superuser membership. SQL-created roles have ordinary PostgreSQL role defaults. See Neon’s role behavior.
The grants above permit reading this table. Check existing PUBLIC privileges and role memberships before using an existing database; creating a reader role does not remove permissions already granted to everyone.
Open Connect in Neon to get the branch’s endpoint hostname and database name. Build the controller’s connection string with freestyle_reader and the password you set above, replacing any owner credentials in the copied string. Percent-encode reserved characters in the username and password. Start with the direct endpoint; the pooled endpoint works too and has -pooler in its hostname. Use the exact hostname from the selected connection string.
Create The VM And Inject Credentials
Create a separate directory on your controller for these ES module examples, install the SDK, and supply the two credentials there:
mkdir neon-controller
cd neon-controller
npm init -y
npm pkg set type=module
npm install freestyle@latest
export FREESTYLE_API_KEY="your-freestyle-api-key"
export NEON_DATABASE_URL="postgresql://freestyle_reader:encoded-password@ep-example.us-east-2.aws.neon.tech/neondb?sslmode=require&channel_binding=require"
Keep these values in your controller’s secret store. Do not put them in the VM, repository, or a snapshot. The script parses Neon’s URL on the controller and sends its credentials only to the Freestyle API. It constructs a separate, password-free connection inside the VM.
Save this as neon-controller.ts:
import { Freestyle } from "freestyle";
import { writeFile } from "node:fs/promises";
const connectionString = process.env.NEON_DATABASE_URL;
if (!connectionString) throw new Error("Set NEON_DATABASE_URL on the controller");
const neon = new URL(connectionString);
if (!["postgres:", "postgresql:"].includes(neon.protocol)) {
throw new Error("NEON_DATABASE_URL must be a PostgreSQL connection string");
}
const username = decodeURIComponent(neon.username);
const password = decodeURIComponent(neon.password);
const database = decodeURIComponent(neon.pathname.slice(1));
if (!username || !password || !database) {
throw new Error("The Neon connection string must include a role, password, and database");
}
if (neon.port && neon.port !== "5432") throw new Error("This example uses port 5432");
const freestyle = new Freestyle();
const { vm, vmId, firewallRules } = await freestyle.vms.create({
// Temporary package-install access; removed before adding database access.
firewall: { rules: [{ action: "allow", source: {}, destination: { public: true } }] },
});
try {
const install = await vm.exec({
linuxUser: "root",
timeoutMs: 300_000,
command: "set -eu\napt-get update -qq\nDEBIAN_FRONTEND=noninteractive apt-get install -y -qq postgresql-client ca-certificates",
});
if (install.statusCode !== 0) throw new Error(install.stderr ?? "Package install failed");
for (const rule of firewallRules) await freestyle.firewall.rules.delete(rule.id);
const rule = await freestyle.tls.rules.create({
action: "allow",
domain: neon.hostname,
source: { vmId },
destination: { public: true },
transform: [{ postgres: { username, password, database } }],
});
// No database password, real username, or real database is sent to vm.exec.
const env = {
PGHOST: neon.hostname,
PGPORT: "5432",
PGUSER: "guest_placeholder",
PGDATABASE: "guest_placeholder",
PGSSLMODE: "verify-full",
PGSSLROOTCERT: "/etc/ssl/certs/ca-certificates.crt",
PGCHANNELBINDING: "disable",
PGCONNECT_TIMEOUT: "10",
PGAPPNAME: "freestyle-neon-guide",
};
const command = 'psql -X --no-password --set=ON_ERROR_STOP=1 --tuples-only --no-align --command "SELECT current_user, current_database(), message FROM guide.messages WHERE id=1;"';
let connected = false;
let lastError = "";
// Allow the new rule, hostname mapping, and CA installation to propagate.
for (let attempt = 0; attempt < 10; attempt++) {
const result = await vm.exec({ command, env, timeoutMs: 30_000 });
if (result.statusCode === 0) {
console.log(result.stdout?.trim());
connected = true;
break;
}
lastError = result.stderr ?? "Connection failed";
await new Promise((resolve) => setTimeout(resolve, 2_000));
}
if (!connected) throw new Error(lastError);
// Save only resource IDs and the public hostname for follow-up commands.
await writeFile("neon-sandbox.json", JSON.stringify({ vmId, ruleId: rule.id, host: neon.hostname }, null, 2), { mode: 0o600 });
console.log({ vmId, ruleId: rule.id });
} catch (error) {
// Deleting this VM also deletes the rules that name it.
await vm.delete();
throw error;
}
Run it on the controller with Node.js 24 or later:
node neon-controller.ts
For the role and database above, the query prints:
freestyle_reader|neondb|Hello from Neon
That result comes from Neon: it proves that the real role and database replaced the placeholders. The guest’s local connection description still shows its placeholder values, so use current_user and current_database() when checking the server-side identity.
The TLS rule installs an exact hostname mapping and the Freestyle CA in the VM, and grants the network path to the edge. After package installation, this VM has no general public-internet firewall grant. Connect by hostname so the client follows the mapping and sends TLS SNI.
Do not copy channel_binding=require into the guest connection. Neon commonly includes it in direct connection strings. Credential injection brokers two TLS sessions and authenticates upstream itself, so the guest cannot require SCRAM channel binding. This example explicitly disables channel binding while retaining full certificate and hostname verification. If your security policy requires channel binding to Neon itself, use a direct connection with guest-held credentials instead of this injector. See Neon’s connection security guidance.
Rotate Or Revoke Access
After changing this role’s password in Neon, put the updated connection string in the controller’s NEON_DATABASE_URL. Replace the rule with the complete configuration; never copy its redacted readback as the new password:
import { Freestyle } from "freestyle";
import { readFile } from "node:fs/promises";
const freestyle = new Freestyle();
const { vmId, ruleId, host } = JSON.parse(await readFile("neon-sandbox.json", "utf8"));
const neon = new URL(process.env.NEON_DATABASE_URL!);
if (neon.hostname !== host) throw new Error("This rotation example keeps the same endpoint");
await freestyle.tls.rules.update(ruleId, {
action: "allow",
domain: host,
source: { vmId },
destination: { public: true },
transform: [{ postgres: {
username: decodeURIComponent(neon.username),
password: decodeURIComponent(neon.password),
database: decodeURIComponent(neon.pathname.slice(1)),
} }],
});
Run node rotate-neon.ts. New connections use the new password after the update propagates; the guest configuration stays the same. Update every rule using the rotated role, including a pooled-endpoint rule if you created one. Coordinate the database and rule changes to account for the interval when their passwords differ.
To revoke new connections, delete the TLS rule. To finish the example, delete the VM too:
import { Freestyle } from "freestyle";
import { readFile, unlink } from "node:fs/promises";
const freestyle = new Freestyle();
const { vmId, ruleId } = JSON.parse(await readFile("neon-sandbox.json", "utf8"));
await freestyle.tls.rules.delete(ruleId);
await freestyle.vms.delete(vmId);
await unlink("neon-sandbox.json");
Run node cleanup-neon.ts. Delete the test branch or database separately in Neon when you are finished with it. Revocation and password rotation do not terminate already authenticated database sessions; terminate those in Neon if immediate revocation is required.
Check The Access Boundary
The real password is sealed at rest and reads back as "***" through the Freestyle API. A process in the authorized VM can use the database access granted to that VM, so SQL permissions remain the boundary for what it can read or change. Keep administrative roles out of injection rules.
Use these checks on a disposable database:
| Check | Expected result |
|---|---|
| Placeholder user/database and no password | Query succeeds as the role and database in the rule |
Insert into guide.messages as the reader | Permission denied |
PGSSLMODE=disable | Connection refused |
| A CA bundle that does not trust the Freestyle certificate | Certificate verification fails |
| A second VM without the matching TLS rule, with network access to the edge | Database connection refused |
| Connection directly to Neon’s IP without a public-internet firewall grant | Blocked |
| Rotate the Neon password, then update the rule | Old injected password fails; new password restores access |
| Delete the rule, then start a new connection | Connection fails |
For applications in containers, carry the hostname mapping and CA trust into the container; see Docker TLS routing. The broker does not route PostgreSQL CancelRequest; use a database-side statement_timeout when a query needs a time limit.