> ## Documentation Index
> Fetch the complete documentation index at: https://docs.zeroset.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Customer-Owned Object Storage

> Store hosted collection graph data in your own S3 bucket

Customer-owned object storage lets a SaaS workspace keep a collection's graph data in a bucket you control while Nebula continues to run the hosted API and graph engine.

<Info>
  This guide applies to hosted SaaS workspaces. Enterprise self-hosted deployments configure object storage at deployment time instead.
</Info>

## How It Works

A workspace owner or admin registers an S3 storage target, validates access, then creates collections bound to that target.

After a collection is bound, Nebula writes that collection's graph objects under:

```text theme={null}
s3://<bucket>/<prefix>/<collection_id>/
```

Collections without a storage target continue using Nebula-managed storage.

## Prerequisites

* An Enterprise-plan team workspace
* An S3 bucket in the region you want to use
* An IAM role Nebula can assume
* Object access for the target prefix

For hosted SaaS, use AWS S3 and omit `endpoint_url`. Custom S3-compatible endpoints are only supported in trusted self-hosted deployments; hosted SaaS rejects custom endpoints.

## Create the IAM Role

Create a role that trusts the Nebula-provided AWS principal and requires the external ID returned when you register the storage target.

The trust policy shape is:

```json theme={null}
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "AWS": "NEBULA_AWS_PRINCIPAL_ARN"
      },
      "Action": "sts:AssumeRole",
      "Condition": {
        "StringEquals": {
          "sts:ExternalId": "EXTERNAL_ID_FROM_NEBULA"
        }
      }
    }
  ]
}
```

Grant object access to the target prefix:

```json theme={null}
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "s3:ListBucket"
      ],
      "Resource": "arn:aws:s3:::CUSTOMER_BUCKET",
      "Condition": {
        "StringLike": {
          "s3:prefix": [
            "nebula/*"
          ]
        }
      }
    },
    {
      "Effect": "Allow",
      "Action": [
        "s3:GetObject",
        "s3:PutObject",
        "s3:DeleteObject"
      ],
      "Resource": "arn:aws:s3:::CUSTOMER_BUCKET/nebula/*"
    }
  ]
}
```

If the bucket uses a customer-managed KMS key, also allow the role to encrypt, decrypt, and generate data keys for that key.

## Register a Storage Target

Register the bucket on the workspace:

<CodeGroup>
  ```python Python theme={null}
  from nebula import Nebula

  async with Nebula(api_key="YOUR_API_KEY") as client:
      target = await client.workspaces.create_storage_target(
          workspace_id="WORKSPACE_ID",
          body={
              "name": "production-s3",
              "bucket": "CUSTOMER_BUCKET",
              "prefix": "nebula",
              "region": "us-east-1",
              "role_arn": "arn:aws:iam::123456789012:role/NebulaStorageAccess",
              "kms_key_id": "OPTIONAL_KMS_KEY_ID",
          },
      )
  ```

  ```typescript JavaScript theme={null}
  import Nebula from '@nebula-ai/sdk';

  const client = new Nebula({ apiKey: 'YOUR_API_KEY' });

  const target = await client.workspaces.createStorageTarget({
    workspaceId: 'WORKSPACE_ID',
    body: {
      name: 'production-s3',
      bucket: 'CUSTOMER_BUCKET',
      prefix: 'nebula',
      region: 'us-east-1',
      role_arn: 'arn:aws:iam::123456789012:role/NebulaStorageAccess',
      kms_key_id: 'OPTIONAL_KMS_KEY_ID',
    },
  });
  ```

  ```bash theme={null}
  curl -X POST "https://api.zeroset.com/v1/workspaces/WORKSPACE_ID/storage-targets" \
    -H "Content-Type: application/json" \
    -H "Authorization: Bearer YOUR_API_KEY" \
    -d '{
      "name": "production-s3",
      "bucket": "CUSTOMER_BUCKET",
      "prefix": "nebula",
      "region": "us-east-1",
      "role_arn": "arn:aws:iam::123456789012:role/NebulaStorageAccess",
      "kms_key_id": "OPTIONAL_KMS_KEY_ID"
    }'
  ```
</CodeGroup>

The response includes:

| Field              | Description                                             |
| ------------------ | ------------------------------------------------------- |
| `id`               | Storage target ID used when creating collections        |
| `external_id`      | External ID to require in the role trust policy         |
| `status`           | `pending`, `active`, `validation_failed`, or `disabled` |
| `validation_error` | Last validation error, if any                           |

If you need the generated `external_id` before finalizing the IAM trust policy, register the target first, update the role trust policy with the returned value, then validate.

## Validate Access

Validation confirms Nebula can access the bucket and prefix:

<CodeGroup>
  ```python Python theme={null}
  from nebula import Nebula

  async with Nebula(api_key="YOUR_API_KEY") as client:
      target = await client.workspaces.validate_storage_target(
          workspace_id="WORKSPACE_ID",
          target_id="STORAGE_TARGET_ID",
      )
  ```

  ```typescript JavaScript theme={null}
  const target = await client.workspaces.validateStorageTarget({
    workspaceId: 'WORKSPACE_ID',
    targetId: 'STORAGE_TARGET_ID',
  });
  ```

  ```bash theme={null}
  curl -X POST "https://api.zeroset.com/v1/workspaces/WORKSPACE_ID/storage-targets/STORAGE_TARGET_ID/validate" \
    -H "Authorization: Bearer YOUR_API_KEY"
  ```
</CodeGroup>

Nebula checks the bucket, writes a small validation object under the target prefix, reads it back, and deletes it. A successful validation sets the target to `active`.

## Create a BYOC Collection

Create collections with both `workspace_id` and `storage_target_id`:

```bash theme={null}
curl -X POST "https://api.zeroset.com/v1/collections" \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -d '{
    "name": "customer-prod-memory",
    "workspace_id": "WORKSPACE_ID",
    "storage_target_id": "STORAGE_TARGET_ID"
  }'
```

Only active storage targets can be used for new collections. Disabling a target prevents future collection binds; existing collections keep their storage binding.

## List or Disable Targets

List targets:

<CodeGroup>
  ```python Python theme={null}
  from nebula import Nebula

  async with Nebula(api_key="YOUR_API_KEY") as client:
      targets = await client.workspaces.list_storage_targets(
          workspace_id="WORKSPACE_ID",
      )
  ```

  ```typescript JavaScript theme={null}
  const targets = await client.workspaces.listStorageTargets('WORKSPACE_ID');
  ```

  ```bash theme={null}
  curl -X GET "https://api.zeroset.com/v1/workspaces/WORKSPACE_ID/storage-targets" \
    -H "Authorization: Bearer YOUR_API_KEY"
  ```
</CodeGroup>

Disable a target:

<CodeGroup>
  ```python Python theme={null}
  from nebula import Nebula

  async with Nebula(api_key="YOUR_API_KEY") as client:
      target = await client.workspaces.disable_storage_target(
          workspace_id="WORKSPACE_ID",
          target_id="STORAGE_TARGET_ID",
      )
  ```

  ```typescript JavaScript theme={null}
  const target = await client.workspaces.disableStorageTarget({
    workspaceId: 'WORKSPACE_ID',
    targetId: 'STORAGE_TARGET_ID',
  });
  ```

  ```bash theme={null}
  curl -X DELETE "https://api.zeroset.com/v1/workspaces/WORKSPACE_ID/storage-targets/STORAGE_TARGET_ID" \
    -H "Authorization: Bearer YOUR_API_KEY"
  ```
</CodeGroup>

## Operational Notes

* Use one bucket prefix per environment, such as `nebula/prod` and `nebula/staging`.
* Do not manually move or delete objects under a collection prefix while the collection is active.
* Workspace deletion is blocked while BYOC-bound collections still exist.
* To use signed URL transport for device memory snapshots, see [Device Memory API](/guides/device-memory-api#customer-owned-snapshot-objects).
