WireGuard remote access VPN

A zero trust remote access VPN over WireGuard

Octelium is a free and open source, self-hosted zero trust access platform. Client-based access runs over WireGuard tunnels and behaves as a zero-config VPN: a single stable route, stable private dual-stack addresses and automatic private DNS. What makes it zero trust is what sits above the tunnel, where every request is authorized on its own.

  • Free and open source
  • Designed for self-hosting

Every request from a human, a browser, a CLI, a workload or an AI agent is authenticated against your identity providers, authorized per request at the application layer by the Octelium Service in front of the resource, exported to your SIEM and observability stack whether it is allowed or denied, and only then routed to the upstream HTTP API, database, SSH server, Kubernetes cluster, LLM or MCP server.

Signing in once, then reaching Services with and without a client

  • A browser opens grafana.acme.com and is redirected to the Cluster sign-in page at acme.com/login.
  • jane signs in with a passkey verified by a biometric on the device, and the browser returns to the Grafana dashboard at grafana.acme.com with no client installed.
  • In a terminal, octelium connect -d opens the browser at acme.com/callback/success/approval, where the same browser session approves the login with one click.
  • ~ ❯ octelium connect -d
  • Please authenticate yourself using Octelium web Portal
  • Waiting for you to approve the login in your browser. Press Ctrl-C to cancel.
  • You are now authenticated as jane
  • Octelium has started running in detached mode.
  • ~ ❯ curl -s my-api/v1/users/42 | jq
  • {
  • "id": 42,
  • "name": "Jane Doe",
  • "email": "jane@acme.com"
  • }
  • ~ ❯ psql -h pg-prod -d shop
  • psql (17.4)
  • Type "help" for help.
  • shop=> SELECT * FROM orders LIMIT 3;
  • id | status | total
  • -----+---------+--------
  • 981 | paid | 129.00
  • 982 | shipped | 48.50
  • 983 | paid | 310.25
  • (3 rows)
  • shop=> \q
  • ~ ❯ ssh bastion
  • Welcome to Ubuntu 24.04.1 LTS (GNU/Linux 6.8.0-1021-aws x86_64)

Requests authorized one by one against CEL rules in the HTTP, LLM and Kubernetes modes

  • HTTP: the HTTP Service billing is configured as follows.
  • kind: Service
  • metadata:
  • name: billing
  • spec:
  • mode: HTTP
  • config:
  • authorization:
  • inlinePolicies:
  • - spec:
  • rules:
  • - effect: ALLOW
  • condition:
  • all:
  • of:
  • - match: '"finance" in ctx.user.spec.groups'
  • - match: ctx.request.http.method == "GET"
  • - match: ctx.request.http.path.startsWith("/invoices")
  • jane, a member of the finance Group, runs curl -i billing/invoices/42 and gets HTTP/1.1 200 OK Content-Type: application/json {"id":42,"status":"paid","total":1290}. The access log records ALLOWED GET /invoices/42 with POLICY_MATCH.
  • jane, a member of the finance Group, runs curl -i -X DELETE billing/invoices/42 and gets HTTP/1.1 403 Forbidden X-Octelium-Unauthorized: true Content-Length: 0. The access log records DENIED DELETE /invoices/42 with NO_POLICY_MATCH.
  • LLM: the LLM Service openai is configured as follows.
  • kind: Service
  • metadata:
  • name: openai
  • spec:
  • mode: LLM
  • config:
  • authorization:
  • inlinePolicies:
  • - spec:
  • rules:
  • - effect: ALLOW
  • condition:
  • any:
  • of:
  • - match: '"ai-admins" in ctx.user.spec.groups'
  • - all:
  • of:
  • - match: '"ai-agents" in ctx.user.spec.groups'
  • - match: ctx.request.llm.operation == "GENERATE"
  • - match: ctx.request.llm.model == "gpt-5-mini"
  • support-bot, a member of the ai-agents Group, runs curl openai/v1/responses --json '{"model":"gpt-5-mini","input":"Hello"}' and gets { "id": "resp_68e8e1f0c4d0", "object": "response", "created_at": 1791628935, "status": "completed", "model": "gpt-5-mini-2025-08-07",. The access log records ALLOWED RESPONSES gpt-5-mini with POLICY_MATCH.
  • support-bot, a member of the ai-agents Group, runs curl openai/v1/responses --json '{"model":"gpt-5","input":"Hello"}' and gets {"error":{"message":"Octelium: unauthorized request","type":"permission_error","param":null,"code":"octelium_unauthorized"}}. The access log records DENIED RESPONSES gpt-5 with NO_POLICY_MATCH.
  • Kubernetes: the KUBERNETES Service k8s-prod is configured as follows.
  • kind: Service
  • metadata:
  • name: k8s-prod
  • spec:
  • mode: KUBERNETES
  • config:
  • authorization:
  • inlinePolicies:
  • - spec:
  • rules:
  • - effect: ALLOW
  • condition:
  • all:
  • of:
  • - match: '"sre-agents" in ctx.user.spec.groups'
  • - match: ctx.request.kubernetes.verb in ["get", "list"]
  • - match: ctx.request.kubernetes.namespace == "production"
  • - not: ctx.request.kubernetes.resource == "secrets"
  • ops-agent, a member of the sre-agents Group, runs kubectl get configmaps -n production and gets NAME DATA AGE app-config 4 12d feature-flags 2 3d kube-root-ca.crt 1 12d. The access log records ALLOWED list configmaps · production with POLICY_MATCH.
  • ops-agent, a member of the sre-agents Group, runs kubectl get secrets -n production and gets Error from server (Forbidden): Octelium: Unauthorized request. The access log records DENIED list secrets · production with NO_POLICY_MATCH.
Secretless access

Keep upstream credentials out of users and workloads

Application-layer credentials are stored in the Cluster as Secrets and injected into the upstream connection once a request is authorized. A tunnel on its own does nothing about the API keys, database passwords and private keys that reach a resource once the tunnel is up.

Read about secretless access

Upstream credentials injected per request by identity in the HTTP, SSH and PostgreSQL modes

  • HTTP: the HTTP Service stripe proxies https://api.stripe.com and picks the upstream credential of every request with dynamic configuration rules.
  • elena, a member of the finance-eu Group, runs curl -s stripe/v1/account | jq '{id, country}' without holding any credential. The rule "finance-eu" in ctx.user.spec.groups selects the eu config, so Octelium injects the Secret stripe-eu and the upstream authenticates the request as acct_1Pq8EuGmbH2bXvK3. The terminal shows { "id": "acct_1Pq8EuGmbH2bXvK3", "country": "DE" }
  • pay-agent, a member of the finance-us Group, runs curl -s stripe/v1/account | jq '{id, country}' without holding any credential. The rule "finance-us" in ctx.user.spec.groups selects the us config, so Octelium injects the Secret stripe-us and the upstream authenticates the request as acct_1Nf2UsQ7cLm4RtW9. The terminal shows { "id": "acct_1Nf2UsQ7cLm4RtW9", "country": "US" }
  • SSH: the SSH Service web-01 proxies ssh://10.0.4.21 and picks the upstream credential of every request with dynamic configuration rules.
  • sam, a member of the sre Group, runs ssh web-01 without holding any credential. The rule "sre" in ctx.user.spec.groups selects the root config, so Octelium injects the Secret root-key and the upstream authenticates the request as root. The terminal shows Welcome to Ubuntu 24.04.3 LTS (GNU/Linux 6.14.0-1015-aws x86_64) root@web-01:~# id uid=0(root) gid=0(root) groups=0(root)
  • noah, a member of the developers Group, runs ssh web-01 without holding any credential. The rule "developers" in ctx.user.spec.groups selects the deploy config, so Octelium injects the Secret deploy-key and the upstream authenticates the request as deploy. The terminal shows Welcome to Ubuntu 24.04.3 LTS (GNU/Linux 6.14.0-1015-aws x86_64) deploy@web-01:~$ id uid=1001(deploy) gid=1001(deploy) groups=1001(deploy)
  • PostgreSQL: the POSTGRES Service orders-db proxies postgres://10.0.2.8:5432 and picks the upstream credential of every request with dynamic configuration rules.
  • priya, a member of the backend Group, runs psql -h orders-db orders without holding any credential. The rule "backend" in ctx.user.spec.groups selects the rw config, so Octelium injects the Secret pg-rw and the upstream authenticates the request as app_rw. The terminal shows psql (17.2) Type "help" for help. orders=> UPDATE orders SET status = 'shipped' WHERE id = 42; UPDATE 1
  • bi-agent, a member of the analytics Group, runs psql -h orders-db orders without holding any credential. The rule "analytics" in ctx.user.spec.groups selects the ro config, so Octelium injects the Secret pg-ro and the upstream authenticates the request as app_ro. The terminal shows psql (17.2) Type "help" for help. orders=> UPDATE orders SET status = 'shipped' WHERE id = 42; ERROR: permission denied for table orders

Signing in with a passkey and opening the Portal

  • The sign-in page of the Cluster at acme.com/login offers login with Okta, with GitHub, or with a passkey.
  • jane@acme.com chooses Login with a Passkey and verifies it with a biometric on the device through native WebAuthn.
  • The browser is redirected to the Portal at portal.acme.com/services, which lists the Services the User can access:
  • grafana.monitoring: Grafana dashboards, Web App, Namespace monitoring.
  • aws-postgres.production: Amazon RDS for PostgreSQL, PostgreSQL, Namespace production.
  • k8s-prod: Production EKS cluster, Kubernetes, Namespace default.
  • bastion.production: SSH bastion host, SSH, Namespace production.
  • github-mcp: GitHub MCP server, MCP, Namespace default.
  • openai: OpenAI API, AI / LLM, Namespace default.
Visibility and auditing

Identity-aware visibility at the application layer

Emit structured, application-layer access logs in real time and export them through OpenTelemetry OTLP to the observability, analytics and SIEM systems your organization already operates.

Read about visibility and auditing
AccessLogOTLP
ALLOWlinus@acme.compg-prod.dbPOSTGRESquery: select id, email from users limit 50
ALLOWci-runnerprod-k8sKUBERNETESverb=list resource=pods namespace=production
DENYagent-07tools-mcpMCPtools/call name=transfer policy=mcp-tools
ALLOWjane@acme.combastionSSHsession recorded, upstream user=deploy
ALLOWagent-07openaiLLMCHAT_COMPLETIONS model=gpt-5-mini tokens=1284
Deployment and scale

Self-hosted, Kubernetes-native and fully programmable

Run the Cluster on Kubernetes infrastructure you control, scale the data plane horizontally, manage resources declaratively, automate the platform through APIs and SDKs, and optionally deploy containerized Service upstreams with the Cluster itself.

Read the management guide

Cluster resources applied declaratively from YAML with octeliumctl and managed programmatically with the Go, TypeScript, Python and Rust SDKs

  • GitOps: cluster.yaml in the main branch of the ~/octelium repository describes the desired state of the Cluster. Its diff reads as follows, with + for added, ~ for changed and - for removed lines.
  • + kind: Group
  • + metadata:
  • + name: payments
  • + spec: {}
  • + ---
  • + kind: User
  • + metadata:
  • + name: alice
  • + spec:
  • + type: HUMAN
  • + email: alice@acme.com
  • + groups: ["payments"]
  • ---
  • kind: User
  • metadata:
  • name: bob
  • spec:
  • type: HUMAN
  • ~ groups: ["payments"]
  • ---
  • kind: Service
  • metadata:
  • name: billing
  • spec:
  • mode: HTTP
  • config:
  • upstream:
  • ~ url: http://10.0.3.24:8080
  • - ---
  • - kind: Service
  • - metadata:
  • - name: billing-legacy
  • - spec: ⋯
  • - ---
  • - kind: User
  • - metadata:
  • - name: john
  • - spec: ⋯
  • ~/octelium ❯ octeliumctl apply --prune .
  • Group: payments Created
  • User: alice Created
  • User: bob Updated
  • Service: billing.default Updated
  • Service: billing-legacy.default Deleted
  • User: john Deleted
  • Cluster Core resources successfully applied
  • 2 resources created
  • 2 resources updated
  • 2 resources deleted
  • Go: main.go uses the octelium-go SDK to list the Users of the Cluster and update the email and Policies of linus.
  • client, err := octelium.New(ctx)
  • if err != nil {⋯}
  • conn, err := client.Conn(ctx)
  • if err != nil {⋯}
  • api := corev1.NewMainServiceClient(conn)
  • users, err := api.ListUser(ctx, &corev1.ListUserOptions{})
  • if err != nil {⋯}
  • for _, u := range users.Items {
  • fmt.Println(u.Metadata.Name)
  • }
  • user, err := api.GetUser(ctx, &metav1.GetOptions{Name: "linus"})
  • if err != nil {⋯}
  • user.Spec.Email = "linus@acme.com"
  • user.Spec.Authorization = &corev1.User_Spec_Authorization{
  • Policies: []string{"db-readonly"},
  • }
  • if _, err := api.UpdateUser(ctx, user); err != nil {⋯}
  • fmt.Println("updated", user.Metadata.Name)
  • ~/app ❯ go run .
  • alice
  • bob
  • linus
  • updated linus
  • TypeScript: main.ts uses the @octelium/sdk SDK to list the Users of the Cluster and update the email and Policies of linus.
  • const client = await OcteliumClient.create({
  • domain: "acme.com",
  • auth: {⋯},
  • });
  • const users = await client.coreV1.listUser(ListUserOptions.create());
  • for (const u of users.response.items) {
  • console.log(u.metadata?.name);
  • }
  • const { response: user } = await client.coreV1.getUser(
  • GetOptions.create({ name: "linus" }),
  • );
  • user.spec!.email = "linus@acme.com";
  • user.spec!.authorization = User_Spec_Authorization.create({
  • policies: ["db-readonly"],
  • });
  • await client.coreV1.updateUser(user);
  • console.log("updated", user.metadata?.name);
  • await client.close();
  • ~/app ❯ npx tsx main.ts
  • alice
  • bob
  • linus
  • updated linus
  • Python: main.py uses the octelium-sdk SDK to list the Users of the Cluster and update the email and Policies of linus.
  • async with await OcteliumClient.create() as client:
  • users = await client.core_v1.list_user(ListUserOptions())
  • for u in users.items:
  • print(u.metadata.name)
  • user = await client.core_v1.get_user(GetOptions(name="linus"))
  • user.spec.email = "linus@acme.com"
  • user.spec.authorization.policies = ["db-readonly"]
  • await client.core_v1.update_user(user)
  • print("updated", user.metadata.name)
  • ~/app ❯ python main.py
  • alice
  • bob
  • linus
  • updated linus
  • Rust: main.rs uses the octelium SDK to list the Users of the Cluster and update the email and Policies of linus.
  • let client = Client::from_env().await?;
  • let mut api = client.core_v1();
  • let users = api.list_user(ListUserOptions::default()).await?;
  • for u in users.into_inner().items {
  • println!("{}", u.metadata.unwrap_or_default().name);
  • }
  • let options = GetOptions {
  • name: "linus".into(),
  • ..Default::default()
  • };
  • let mut user = api.get_user(options).await?.into_inner();
  • let spec = user.spec.get_or_insert_default();
  • spec.email = "linus@acme.com".into();
  • spec.authorization.get_or_insert_default().policies = vec!["db-readonly".into()];
  • let user = api.update_user(user).await?.into_inner();
  • println!("updated {}", user.metadata.unwrap_or_default().name);
  • ~/app ❯ cargo run -q
  • alice
  • bob
  • linus
  • updated linus
The client

What the client does on the host, mode by mode

WireGuard is the transport, and the client adapts to whatever privileges it has. These are the parts that matter in practice, next to what each one actually does.

  1. 01

    Kernel WireGuard where it exists

    On Linux 5.6 and later, or any kernel with the WireGuard module loaded, the client uses the kernel implementation, which is by far the most performant mode. It needs root or the NET_ADMIN capability.

    Read more
  2. 02

    A userspace TUN device otherwise

    The wireguard-go TUN device implementation covers every platform, including macOS and Windows. It also needs permission to create a TUN device, and can be selected explicitly over the kernel mode.

    Read more
  3. 03

    No privileges at all, if necessary

    Without permission to create a TUN device, the client runs its own userspace network stack on gVisor Netstack as an ordinary non-root process. Services are then reached by mapping them to the host, which is what makes the client usable inside containers, CI runners and confined environments.

    Read more
  4. 04

    One route, not a routing table

    Services are addressed through the Cluster rather than through the networks their upstreams sit on, so the client keeps a single stable route however many environments, clouds and clusters are behind it. There are no remote subnets to advertise and no overlapping ranges to reconcile.

    Read more
  5. 05

    Dual-stack without translation

    The client uses a private IPv6 address by default and can be forced to IPv4-only or dual-stack, independently of what each upstream speaks, so there is no NAT64 or DNS64 to operate between them.

    Read more
  6. 06

    Private DNS that configures itself

    Every Service has a stable private FQDN assigned by the Cluster, resolved through a split DNS server the client sets up automatically using resolvectl on Linux and networksetup on macOS. Nothing has to be memorized or kept in sync as upstream addresses change.

    Read more
  7. 07

    Serving, as well as consuming

    The same client can serve resources to the Cluster with the --serve and --serve-all flags, so a laptop, a container or a pod in another cluster becomes a reachable upstream with no inbound port opened beside it.

    Read more
  8. 08

    Authorization above the tunnel

    Bringing the tunnel up grants nothing on its own. Every request is authorized against identity, device, session and the application-layer request itself, which is the part a WireGuard configuration cannot express.

    Read more

A WireGuard configuration describes peers, keys and allowed addresses. None of those are identities, and none of them can express that a particular person may run one query on one database at one time of day.

Questions

Frequently asked

Is this real WireGuard?
Yes. Client-based access runs over WireGuard, using the kernel implementation where it is available and an unprivileged userspace implementation elsewhere. A QUIC-based tunneling mode is supported alongside it. Everything above the transport, including identity, Policies, secretless access and auditing, is identical either way.
Do clients need root privileges?
Not necessarily. On Linux the client first tries the WireGuard kernel implementation, which needs root or the NET_ADMIN capability. Without those it falls back to a wireguard-go TUN device, and without permission to create a TUN device it runs an unprivileged userspace network stack over gVisor Netstack. In that last mode Services are reached by mapping them to the host, which is what makes the client work inside containers, CI runners and IoT devices.
Do I have to manage peers and keys?
No. There is no peer list to maintain, no key to distribute and no configuration file to hand out. A person runs octelium connect, authenticates through your identity provider, and the Cluster establishes the tunnel and assigns addressing and DNS. A Session can be revoked centrally, which ends access immediately.
What about routes and split tunneling?
Each Service is reached through a single stable route, so there are no remote subnets to advertise into a user's routing table and no overlapping ranges to reconcile. Because only Cluster traffic uses the tunnel, ordinary internet traffic is never pulled through a gateway to keep a connection usable, which is the usual reason full tunneling gets switched on.
Can resources behind NAT be reached without opening a port?
Yes. A Service upstream can be an address that is only reachable from the side of a connected client, which serves it to the Cluster over its own outbound tunnel using the --serve or --serve-all flags. That covers a laptop behind NAT, containers, Kubernetes pods in other clusters, private clouds and IoT devices, with no inbound port and no firewall change on that side.
Is Octelium free and open source?
Yes. Octelium is designed for self-hosting and does not require a proprietary hosted control plane. The core platform is free and open source. An optional enterprise package adds capabilities for organizations with additional operational, identity-management and security requirements, including the web console, SCIM provisioning and Secret encryption at rest.
Get started

Run Octelium on your own infrastructure

Install a self-hosted Cluster and start applying identity-aware access control to your Services.