Freestyle Docs

Freestyle / Guides

Connect to PostHog With Credential Injection

Query PostHog's private API from a Freestyle VM while keeping the real personal API key at the Freestyle edge.

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:

CheckExpected result
POST to the exact project query path with the placeholderQuery succeeds because Freestyle injects the real key
Same path with GETNo injection; PostHog rejects the request or method
Different private API pathNo injection; PostHog returns an authentication error
Same request from another VM with no egress grantNo matching route; connection fails
Direct connection to the host’s IPBlocked because the VM has no public firewall grant
Read the TLS rule through the APIAuthorization 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.

esc