Kynetra FX

Documentation

Self-Hosting

Kynetra FX is runtime-agnostic. While Cloudflare Workers is the recommended deployment target for edge performance, every package runs equally well on Node.js — in a container, on a VM, or behind a Kubernetes ingress. This guide covers everything you need to go from a local dev server to a production-grade self-hosted deployment.

Cloudflare Workers vs self-hosting#

Choosing where to run your app comes down to operational trade-offs. Cloudflare Workers gives you global edge latency, zero cold-start, and no infrastructure to manage. Self-hosting gives you full control: persistent connections, long-running processes, arbitrary native binaries, and the ability to run in a private network without egress to the public internet.

  • Choose Cloudflare Workers when latency is the primary concern and your workload fits within the request/response model — no long-running tasks, no native modules, no filesystem.
  • Choose self-hosting when you need persistent WebSocket connections, a relational database in the same datacenter, native Node.js modules, or strict data residency requirements.
  • Hybrid — run the edge layer on Cloudflare Workers and offload compute-heavy or stateful work to a self-hosted service via ctx.fetch().

Tip

The runtime option passed to createFX() is the only thing that changes between Cloudflare and Node deployments. All middleware, routes, and kernel code are identical.

Installing the Node adapter#

Self-hosted deployments use @kynetra/fx-node, which wraps the framework's Web-standard request/response model in a native Node.js HTTP server. Install it alongside the core package.

terminal
npm install @kynetra/fx @kynetra/fx-node @kynetra/fx-saas

Application entry point#

The entry point wires together the app, middleware, and the serve() call that starts the HTTP server. The runtime: 'node' flag tells the framework to use Node-compatible crypto and timing APIs instead of the Workers runtime.

src/index.ts
import { createFX, cors, logger, requestId, secureHeaders } from '@kynetra/fx'
import { serve } from '@kynetra/fx-node'
import { createSaasKernel } from '@kynetra/fx-saas'
 
const app = createFX({ runtime: 'node' })
 
app.use(requestId())
app.use(logger())
app.use(secureHeaders())
app.use(cors({ origin: process.env.ALLOWED_ORIGINS?.split(',') ?? [] }))
 
// Kernel with in-memory store by default — swap for Postgres in production
const kernel = createSaasKernel()
 
app.decorate('kernel', kernel)
 
app.get('/health', (ctx) => ctx.json({ status: 'ok' }))
 
serve(app, { port: parseInt(process.env.PORT ?? '3000') })

Note

createSaasKernel() uses an in-memory StorePort by default. This is fine for local development and integration tests, but you must swap it for a durable store before going to production. See Port adapters below.

Docker setup#

The recommended container build uses a two-stage Dockerfile: a builder stage that compiles TypeScript and prunes devDependencies, and a lean runner stage that ships only the compiled output and production modules.

Dockerfile
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
 
FROM node:20-alpine AS runner
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
EXPOSE 3000
ENV NODE_ENV=production
CMD ["node", "dist/index.js"]

Docker Compose for local development

Use Docker Compose to run the API alongside a Postgres database. Secrets are passed as environment variables, which you can supply via a .env file at the project root. Never commit secrets to the image.

docker-compose.yml
version: '3.9'
services:
api:
build: .
ports:
- "3000:3000"
environment:
- NODE_ENV=production
- PORT=3000
- DATABASE_URL=postgresql://user:pass@db:5432/myapp
- JWT_SECRET=${JWT_SECRET}
depends_on:
- db
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: user
POSTGRES_PASSWORD: pass
POSTGRES_DB: myapp
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata:

Tip

Run docker compose up --build to rebuild the API image whenever your source changes. Use docker compose up -d in CI to start services in detached mode before running integration tests.

Environment variables and secrets#

All runtime configuration is read from environment variables. The table below documents the variables that Kynetra FX and its standard adapters recognise. Your application can define additional variables as needed.

NameTypeDescription
NODE_ENVstringSet to "production" to disable verbose error output and enable performance optimisations.
PORTnumberHTTP port the server listens on. Defaults to 3000.
JWT_SECRETstringSecret used by jwtAuth() and signSession(). Must be at least 32 random bytes in production.
DATABASE_URLstringPostgreSQL connection string used by the pg StorePort adapter.
ALLOWED_ORIGINSstringComma-separated list of origins passed to cors(). Leave unset to disallow all cross-origin requests.
LOG_LEVELstringMinimum log level: "debug" | "info" | "warn" | "error". Defaults to "info".

Warning

Never hard-code secrets in your application source or Dockerfile. Use Docker secrets, Kubernetes Secrets, or a dedicated secrets manager (HashiCorp Vault, AWS Secrets Manager) to inject credentials at runtime.

Port adapters#

Kynetra FX uses the ports and adapters pattern — the framework defines abstract interfaces (StorePort, QueuePort, BlobPort) and you supply concrete implementations. This lets you swap databases and infrastructure without touching application logic.

Custom StorePort for Postgres

The following adapter implements StorePort on top of the pg package. Pass the adapter to createSaasKernel() to replace the default in-memory store.

src/adapters/pg-store.ts
import type { StorePort } from '@kynetra/fx-ports'
import { Pool } from 'pg'
 
function pgStore(pool: Pool): StorePort {
return {
async get<T>(collection: string, id: string) {
const { rows } = await pool.query(
'SELECT data FROM store WHERE collection = $1 AND id = $2',
[collection, id]
)
return rows[0] ? (JSON.parse(rows[0].data) as T) : null
},
async list<T>(collection: string) {
const { rows } = await pool.query(
'SELECT data FROM store WHERE collection = $1',
[collection]
)
return rows.map((r) => JSON.parse(r.data) as T)
},
async put<T extends { id: string }>(collection: string, record: T) {
await pool.query(
`INSERT INTO store (collection, id, data) VALUES ($1, $2, $3)
ON CONFLICT (collection, id) DO UPDATE SET data = $3`,
[collection, record.id, JSON.stringify(record)]
)
return record
},
async delete(collection: string, id: string) {
await pool.query('DELETE FROM store WHERE collection = $1 AND id = $2', [collection, id])
},
}
}
 
export { pgStore }

Wiring the adapter into the kernel

src/index.ts
import { Pool } from 'pg'
import { createSaasKernel } from '@kynetra/fx-saas'
import { pgStore } from './adapters/pg-store'
 
const pool = new Pool({ connectionString: process.env.DATABASE_URL })
 
const kernel = createSaasKernel({
store: pgStore(pool),
})

Adapters for Redis (QueuePort) and S3-compatible storage (BlobPort) follow the same pattern. See D1 for the Cloudflare-native equivalent that ships as @kynetra/fx-cloudflare.

Store schema migration#

When using the Postgres StorePort adapter, create the backing table before starting the application. Run this migration once against your database.

migrations/001_store.sql
CREATE TABLE IF NOT EXISTS store (
collection TEXT NOT NULL,
id TEXT NOT NULL,
data JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (collection, id)
);
 
CREATE INDEX ON store (collection);

Health checks#

Always expose a health check endpoint so your container orchestrator can determine when the service is ready to receive traffic. The entry point example above already includes one at /health. Extend it to probe downstream dependencies if needed.

src/index.ts
app.get('/health', async (ctx) => {
// Optional: probe the database connection
try {
await pool.query('SELECT 1')
return ctx.json({ status: 'ok', db: 'ok' })
} catch (err) {
return ctx.json({ status: 'degraded', db: 'error' }, { status: 503 })
}
})

Production checklist#

Run through this list before routing production traffic to a self-hosted deployment.

  • Set NODE_ENV=production — disables stack traces in error responses and enables output caching.
  • Use a strong JWT_SECRET — at least 32 cryptographically random bytes. Generate one with openssl rand -hex 32.
  • Enable secureHeaders() and cors() middleware with an explicit origin allowlist.
  • Run behind a reverse proxy (nginx or Caddy) for TLS termination and HTTP/2.
  • Expose a /health endpoint and configure liveness/readiness probes in your orchestrator.
  • Replace the in-memory StorePort with a durable adapter (Postgres, Redis) — in-memory state is lost on restart.
  • Ship structured JSON logs to an aggregator (Datadog, Loki, CloudWatch) — the built-in logger() middleware outputs NDJSON.
  • Set CPU and memory resource limits in your Docker Compose or Kubernetes manifest.
  • Listen for SIGTERM and drain in-flight requests before exiting for graceful shutdown.
  • Run database migrations as a separate init step before the application container starts.
1

Build the image

Run docker build -t myapp:latest . and verify the image size. A typical Kynetra FX app compiles to under 5 MB of JavaScript; the full image with Node.js Alpine should be under 200 MB.
2

Inject secrets at runtime

Pass JWT_SECRET and DATABASE_URL as environment variables from your secrets manager — never bake them into the image. Use Docker secrets or a Kubernetes Secret mounted as environment variables.
3

Run migrations

Execute your SQL migrations against the database before starting the API container. In Docker Compose, use an entrypoint or a dedicated migration service that runs to completion before the api service starts.
4

Start the service

Run docker compose up -d (or apply your Kubernetes manifests). Confirm the health check returns 200 ok before enabling traffic.
5

Verify structured logs

Tail the container logs and confirm you see NDJSON lines from logger(). Wire them into your log aggregator before the service receives production load.