# Connect a sandbox to Tailscale (https://tenki.cloud/docs/sandbox/tailscale)

> For the complete documentation index, see [llms.txt](https://tenki.cloud/llms.txt)

Join a sandbox to your tailnet with a Tailscale auth key.

Pass a Tailscale auth key when you create a sandbox, and the sandbox joins your tailnet as its own device.
Your tailnet devices can reach it by its Tailscale IP or MagicDNS name, and the sandbox can reach them the same way.

Tailscale attachment is in preview and is enabled per workspace.
Contact Tenki support to turn it on for yours.

You need the Tenki CLI `2.3.0` or later, or SDK version `1.4.0` or later.

## Create an auth key

In the Tailscale admin console, open [Keys](https://login.tailscale.com/admin/settings/keys) and generate an auth key with these settings:

* **Reusable**, so one key can join many sandboxes.
  A single-use key joins only the first sandbox; later sandboxes stay in `CREATING` until the wait times out.
* **Ephemeral**, so Tailscale removes the device when the sandbox is terminated.
* **Pre-approved**, if your tailnet requires device approval.
* **Tags**, if your access controls grant access by tag.
* **A short expiration**, such as one day, since a reusable key can enroll any number of devices until it expires.
  Revoke it when you no longer need it.

Save the key to a file that only you can read.
Paste the key, press Enter, then press Ctrl-D:

```bash
umask 077
cat > ~/tailscale-auth-key
```

Delete the file when you are done, or skip it and pass the key on stdin with `--tailnet-auth-key-file -`.

Tenki reads the key once at create, never returns it, and clears it when the sandbox is terminated.
The sandbox itself never sees the key.

## Create a sandbox on your tailnet

**TypeScript**
```typescript
import { readFileSync } from "node:fs";
import { TailnetAuthKey, TenkiSandbox } from "@tenkicloud/sandbox";

const key = readFileSync(`${process.env.HOME}/tailscale-auth-key`, "utf8").trim();

const session = await new TenkiSandbox().create({
  tailnet: {
    authKey: new TailnetAuthKey(key),
    hostname: "my-sandbox",
    ephemeral: true,
    waitForOnline: true,
  },
});
console.log(session.tailnetStatus?.state, session.tailnetStatus?.fqdn);
```

**Python**
```python
from pathlib import Path

from tenki import Sandbox, TailnetAttachment, TailnetAuthKey

key = (Path.home() / "tailscale-auth-key").read_text().strip()

sb = Sandbox.create(
    tailnet=TailnetAttachment(
        auth_key=TailnetAuthKey(key),
        hostname="my-sandbox",
        ephemeral=True,
        wait_for_online=True,
    )
)
if sb.tailnet_status:
    print(sb.tailnet_status.state, sb.tailnet_status.fqdn)
```

**Go**
```go
package main

import (
	"context"
	"fmt"
	"log"
	"os"
	"path/filepath"
	"strings"

	tenkisandbox "github.com/LuxorLabs/tenki-sdk-go/sandbox"
)

func main() {
	ctx := context.Background()
	client, err := tenkisandbox.New()
	if err != nil {
		log.Fatal(err)
	}
	defer client.Close()

	home, err := os.UserHomeDir()
	if err != nil {
		log.Fatal(err)
	}
	key, err := os.ReadFile(filepath.Join(home, "tailscale-auth-key"))
	if err != nil {
		log.Fatal(err)
	}

	session, err := client.Create(ctx, tenkisandbox.WithTailnet(tenkisandbox.TailnetAttachment{
		AuthKey:       tenkisandbox.NewTailnetAuthKey(strings.TrimSpace(string(key))),
		Hostname:      "my-sandbox",
		Ephemeral:     true,
		WaitForOnline: true,
	}))
	if err != nil {
		log.Fatal(err)
	}
	if status := session.TailnetStatus; status != nil {
		fmt.Println(status.State, status.FQDN)
	}
	fmt.Println("session_id", session.ID)
}
```

**CLI**
```bash
tenki sandbox create \
  --tailnet-provider tailscale \
  --tailnet-auth-key-file ~/tailscale-auth-key \
  --tailnet-hostname my-sandbox \
  --tailnet-ephemeral \
  --tailnet-wait-for-online
```

With wait for online, create returns after the sandbox is `RUNNING` and its Tailscale device is `online`, usually within a few seconds.
The SDKs wrap the key in `TailnetAuthKey`, which prints as `[REDACTED]` in logs and debug output.

`tenki sandbox create` makes the new sandbox the CLI's current session, so the `get`, `exec`, and `terminate` commands on this page act on it.
A sandbox created with an SDK does not become the CLI's current session.
Run `tenki sandbox set <session-id>` first, or pass `--session <session-id>` to each command, so they act on the right sandbox.

Check the attachment at any time:

```bash
tenki sandbox get
```

The output includes `tailnet_state` and `tailnet_fqdn`, for example `my-sandbox.your-tailnet.ts.net`.
Add `--json` to see the device's Tailscale IPs under `tailnet_status.ips`.

## Reach the sandbox and your tailnet

From a device allowed by your tailnet's ACLs or grants, use the returned `tailnet_fqdn`:

```bash
tailscale ping my-sandbox.your-tailnet.ts.net
```

Connect by `tailnet_fqdn` or Tailscale IP rather than the hostname you requested.
If that hostname is already taken on your tailnet, Tailscale assigns a different one, such as `my-sandbox-1`, and the requested name still points at the other device.

Connections from your tailnet into the sandbox reach only the ports you expose at create.
For example, add `--tailnet-expose-port 8080` to allow TCP connections to a service listening on `0.0.0.0:8080` in the sandbox; other ports stay closed on the tailnet path.

From inside the sandbox, reach tailnet devices by Tailscale IP or MagicDNS name:

```bash
tenki sandbox exec -- curl -fsS http://my-laptop.your-tailnet.ts.net:8080/
```

## Preview URLs and tailnet access

A Tailscale-attached sandbox can serve the same application through two independent inbound paths:

| Access path       | Route                                                                            | Enable it with                              |
| ----------------- | -------------------------------------------------------------------------------- | ------------------------------------------- |
| Tailnet TCP       | Allowed tailnet peer to the sandbox's Tailscale IP or MagicDNS name and TCP port | `--tailnet-expose-port 8080` at create time |
| Tenki preview URL | Browser to Tenki's public HTTPS gateway, then to the sandbox's application port  | `tenki sandbox preview-url create`          |

Attaching Tailscale does not make a Tenki preview URL tailnet-only or apply your tailnet's ACLs to the preview gateway.
Preview visitors do not need a Tailscale client or tailnet membership.

With your application listening on port 8080, create a preview URL:

```bash
tenki sandbox preview-url create --slug my-app --session <session-id> --port 8080
```

Open the returned URL in a browser.
The slug is part of the public hostname, and anyone with the URL can reach the port.
Tenki serves HTTPS at the gateway, so the application can serve plain HTTP on port 8080 inside the sandbox.
This path works without `--tailnet-expose-port 8080`.
Conversely, `--tailnet-expose-port 8080` enables private tailnet forwarding and does not create a Tenki preview URL.

For tailnet-only access, use the returned Tailscale IP or FQDN and leave the application unexposed through Tenki's preview gateway.
If you already created a preview URL for that port, delete it:

```bash
tenki sandbox preview-url list --session <session-id>
tenki sandbox preview-url delete <preview-url-id>
```

Unbinding or unexposing only takes the URL offline; it stays in the workspace and counts toward your preview URL limit until you delete it.

See [port exposure and networking](https://tenki.cloud/docs/sandbox/sessions.md#port-exposure-and-networking) for the SDK equivalents.

## Restrict public HTTPS requests

By default, the sandbox's public internet traffic goes through Tenki's egress policy, as it does without Tailscale.
Use a [saved HTTPS policy](https://tenki.cloud/docs/sandbox/sessions.md#https-request-rules-and-saved-policies) to allow registry downloads and GitHub clone/fetch requests while restricting other HTTP methods and paths.
Create the policy in the same workspace, then add `--egress-policy registries` to the CLI create command above.
In Go, combine `tenkisandbox.WithEgressPolicies("registries")` with `tenkisandbox.WithTailnet(...)`; `WithEgressPolicies` requires the Go SDK installed from `main`.

Egress policies apply to public internet traffic only.
Traffic from the sandbox to devices on your tailnet is not checked against the allowed-domain list, so restrict it with your tailnet's ACLs or grants.
Egress policies also do not control inbound preview access or expose a guest port to the tailnet.

## Terminate the sandbox

```bash
tenki sandbox terminate --session <session-id>
```

Tenki logs the device out of your tailnet.
With an ephemeral key, Tailscale removes the device within seconds, and it no longer appears on the Machines page.
With a non-ephemeral key, the device stays on the Machines page as expired until you remove it there.

## Options

| Option                                                | Description                                                             |
| ----------------------------------------------------- | ----------------------------------------------------------------------- |
| `--tailnet-provider`<br />SDK: `provider`             | Attachment provider. Only `tailscale` is supported.                     |
| `--tailnet-auth-key-file`<br />SDK: `authKey`         | File containing the auth key, or `-` to read it from stdin.             |
| `--tailnet-hostname`<br />SDK: `hostname`             | Device name on your tailnet.                                            |
| `--tailnet-tag`<br />SDK: `tags`                      | Tag to request for the device. Repeat for more than one.                |
| `--tailnet-ephemeral`<br />SDK: `ephemeral`           | Request an ephemeral device. Use an ephemeral auth key as well.         |
| `--tailnet-wait-for-online`<br />SDK: `waitForOnline` | Return only after the device is online on your tailnet.                 |
| `--tailnet-expose-port`<br />SDK: `exposePorts`       | Sandbox TCP port reachable from your tailnet. Repeat for more than one. |
| `--tailnet-accept-routes`<br />SDK: `acceptRoutes`    | Accept subnet routes advertised on your tailnet.                        |

SDK names are for TypeScript. Python uses the same names in snake case, for example `wait_for_online`.
Go uses the same names in Pascal case, for example `WaitForOnline`.

## Troubleshooting

**Create fails or times out before the attachment is online.**
A timeout alone does not mean the auth key was rejected.
Inspect the allocated session with `tenki sandbox get --session <session-id> --json`, checking `tailnet_status.state`, `tailnet_status.error`, and `terminal_error` when present.
If the error identifies a rejected enrollment credential, the key may be expired, revoked, or a single-use key that already joined another sandbox; generate a new reusable key and try again.
Other failures can involve device approval, provider availability, or sandbox provisioning and readiness.

Without wait for online, create can return a `RUNNING` sandbox before enrollment finishes.
A later enrollment failure can leave the attachment `offline` or in `error` while the sandbox remains running.
If enrollment fails before creation completes, create can fail instead.

**Terminated sandboxes still appear on the Machines page.**
The auth key is not ephemeral.
Remove the devices on the Machines page, and use an ephemeral key for new sandboxes.