Call PostHog’s private API from a sandbox without putting the real API key in
the guest. Your trusted controller stores a scoped personal API key in a
Freestyle HTTP egress rule. The VM sends PostHog’s standard
Authorization: Bearer header with a placeholder value, and the Freestyle edge
replaces it on the way out.
This example runs a read-only HogQL query with the query API. The same pattern works for another private PostHog endpoint after you change the path matcher and give the key only the scope that endpoint requires.
Create A Scoped PostHog Key
Create a personal API key in PostHog with these limits:
- Grant only
query:read. - Restrict its access to the project this VM needs.
- Give it a label that identifies the sandbox integration.
Personal API keys can carry the same access as a signed-in user, so do not use
an unscoped account-wide key. PostHog’s project secret API keys are currently in
beta and do not list query:read among their supported scopes. This query
example therefore uses a personal API key.
Copy the project ID from PostHog’s project settings. Choose the private API host for the project’s region:
- US Cloud uses
https://us.posthog.com. - EU Cloud uses
https://eu.posthog.com. - A self-hosted project uses its own HTTPS origin on port 443.
Do not use us.i.posthog.com or eu.i.posthog.com here. Those are public event
ingestion hosts. Private APIs use the app host.
Set the values on your controller:
export FREESTYLE_API_KEY="your-freestyle-api-key"
export FREESTYLE_NODE_SNAPSHOT_ID="snapshot-id-from-the-node-guide"
export POSTHOG_API_HOST="https://us.posthog.com"
export POSTHOG_PROJECT_ID="your-numeric-project-id"
export POSTHOG_PERSONAL_API_KEY="phx_your-personal-api-key"
Create FREESTYLE_NODE_SNAPSHOT_ID with the reusable snapshot from the
Node.js sandbox guide. The code below uses
Node’s built-in fetch, so the VM needs no PostHog package.
Create The VM And Inject Authentication
Install the Freestyle SDK on your controller:
npm install freestyle@latest
Save this as posthog-controller.ts. It creates a VM with no general Internet
firewall rule, then grants one named route to the selected PostHog host. The
rule injects the real Bearer key only for the project’s blocking query endpoint:
import { Freestyle, type CreateTlsRuleOptions } from "freestyle";
function requiredEnv(name: string): string {
const value = process.env[name]?.trim();
if (!value) throw new Error(`Missing ${name}`);
return value;
}
const posthogHost = new URL(requiredEnv("POSTHOG_API_HOST"));
if (
posthogHost.protocol !== "https:" ||
(posthogHost.port && posthogHost.port !== "443") ||
posthogHost.username ||
posthogHost.password ||
posthogHost.pathname !== "/" ||
posthogHost.search ||
posthogHost.hash
) {
throw new Error("POSTHOG_API_HOST must be an HTTPS origin on port 443");
}
const projectId = requiredEnv("POSTHOG_PROJECT_ID");
if (!/^\d+$/.test(projectId)) {
throw new Error("POSTHOG_PROJECT_ID must be numeric");
}
const queryPath = `/api/projects/${projectId}/query/`;
function posthogQueryRule(
vmId: string,
apiKey: string,
): CreateTlsRuleOptions {
return {
action: "allow",
domain: posthogHost.hostname,
source: { vmId },
destination: { public: true },
match: { method: ["POST"], path: { exact: queryPath } },
transform: [
{ headers: { authorization: `Bearer ${apiKey}` } },
],
};
}
const freestyle = new Freestyle();
const { vm, vmId } = await freestyle.vms.create({
snapshotId: requiredEnv("FREESTYLE_NODE_SNAPSHOT_ID"),
firewall: { rules: [] },
slug: "posthog-client",
});
const route = await freestyle.tls.rules.create(
posthogQueryRule(vmId, requiredEnv("POSTHOG_PERSONAL_API_KEY")),
);
console.log({ vmId, ruleId: route.id });
The real PostHog key is sent from the controller to the Freestyle API, sealed at
rest, and returned as "***" when the rule is read. It is never sent to
vm.exec, written to the guest filesystem, or captured in a snapshot.
The VM still receives a capability: code running there can make authenticated
queries through this rule. The key’s query:read scope and project restriction
bound that capability. Use a separate VM or VPC source when different workloads
need different PostHog access.
Query PostHog With A Placeholder Key
Continue in posthog-controller.ts. Write a script that asks for the five most
recent event names and timestamps. Its Bearer value is deliberately invalid:
await vm.fs.writeTextFile(
"/opt/query-posthog.mjs",
`function requiredEnv(name) {
const value = process.env[name]?.trim();
if (!value) throw new Error("Missing " + name);
return value;
}
const apiHost = new URL(requiredEnv("POSTHOG_API_HOST"));
const projectId = requiredEnv("POSTHOG_PROJECT_ID");
const url = new URL("/api/projects/" + projectId + "/query/", apiHost);
const response = await fetch(url, {
method: "POST",
headers: {
authorization: "Bearer unused-placeholder",
"content-type": "application/json",
},
body: JSON.stringify({
query: {
kind: "HogQLQuery",
query: "select event, timestamp from events order by timestamp desc limit 5",
},
name: "freestyle sandbox query",
}),
});
if (!response.ok) {
throw new Error("PostHog returned " + response.status + ": " + await response.text());
}
console.log(JSON.stringify(await response.json(), null, 2));
`,
);
The script contains no real credential. Freestyle replaces the placeholder header only when the hostname, method, source VM, and exact path match the rule.
Wait for the route, hostname mapping, and Freestyle CA to reach the VM before running the query. The readiness check opens the same hostname without calling the query endpoint, so it cannot execute the query twice:
const node = "export HOME=/root NVM_DIR=/opt/nvm && . $NVM_DIR/nvm.sh &&";
const guestEnv = {
POSTHOG_API_HOST: posthogHost.origin,
POSTHOG_PROJECT_ID: projectId,
NODE_EXTRA_CA_CERTS: "/etc/ssl/certs/ca-certificates.crt",
};
let routeReady = false;
let lastError = "PostHog route did not become ready";
for (let attempt = 0; attempt < 10; attempt++) {
const check = await vm.exec({
command:
`${node} node --input-type=module ` +
`-e 'await fetch(process.env.POSTHOG_API_HOST, { redirect: "manual" })'`,
env: guestEnv,
timeoutMs: 30_000,
});
if (check.statusCode === 0) {
routeReady = true;
break;
}
lastError = check.stderr ?? lastError;
await new Promise((resolve) => setTimeout(resolve, 2_000));
}
if (!routeReady) throw new Error(lastError);
const result = await vm.exec({
command: `${node} node /opt/query-posthog.mjs`,
env: guestEnv,
timeoutMs: 60_000,
});
if (result.statusCode !== 0) {
throw new Error(result.stderr ?? "PostHog query failed");
}
console.log(result.stdout);
NODE_EXTRA_CA_CERTS makes Node trust the CA that Freestyle installs for the
named egress route. Keep certificate verification enabled. A successful
response contains PostHog’s JSON result for the query.
HogQL can expose event and person data available to the project. Keep query text in reviewed code, and do not concatenate untrusted input into a query string.
Understand The Route Boundary
The exact-path matcher selects when Freestyle injects the real key. It is not a deny rule. A request to another path on the same PostHog hostname still follows the named route, but it keeps the invalid placeholder and should fail private API authentication.
These checks show the boundary:
| Check | Expected result |
|---|---|
POST to the exact project query path with the placeholder | Query succeeds because Freestyle injects the real key |
Same path with GET | No injection; PostHog rejects the request or method |
| Different private API path | No injection; PostHog returns an authentication error |
| Same request from another VM with no egress grant | No matching route; connection fails |
| Direct connection to the host’s IP | Blocked because the VM has no public firewall grant |
| Read the TLS rule through the API | Authorization value is redacted as "***" |
The rule does not inspect or restrict the HogQL body. Any process in the source VM can submit a different read query to the same endpoint. PostHog key scopes, project access, and the VM boundary remain the authorization controls.
Rotate Or Remove Access
Create a replacement personal API key with the same narrow scope, load it into the controller, and replace the complete rule:
await freestyle.tls.rules.update(
route.id,
posthogQueryRule(vmId, requiredEnv("POSTHOG_PERSONAL_API_KEY")),
);
After the update propagates, run a new query before deleting the old PostHog key. A rule read cannot reconstruct its redacted secret, so every update must include the real replacement value.
Delete the route and VM when the workload no longer needs them:
await freestyle.tls.rules.delete(route.id);
await vm.delete();
Deleting the Freestyle rule removes the VM’s grant. Revoke the personal API key in PostHog when the credential itself should stop working anywhere else.