# How to Configure S3 for File Storage and Attachments in Kaneo

> Configure S3 for Kaneo file storage and attachments easily. Learn how to set up environment variables for secure uploads and private object retrieval in your Kaneo application.

- Repository: [kaneo.app/kaneo](https://github.com/usekaneo/kaneo)
- Tags: how-to-guide
- Published: 2026-08-06

---

**Kaneo uses a S3-compatible object store for file attachments, configured entirely through environment variables in [`apps/api/src/storage/s3.ts`](https://github.com/usekaneo/kaneo/blob/main/apps/api/src/storage/s3.ts) where the API generates presigned PUT URLs for uploads and handles private object retrieval.**

All uploaded files in Kaneo—including task description images and comment attachments—flow through a unified S3 storage layer. This guide walks through every environment variable, credential strategy, and code pattern you need to enable reliable, scalable file storage in your Kaneo deployment.

---

## Required Environment Variables for S3 Storage

The Kaneo API reads S3 configuration directly from `process.env`, loaded via `dotenv-mono`. At minimum, you must specify an endpoint and bucket name.

| Variable | Purpose | Required? |
|----------|---------|-----------|
| `S3_ENDPOINT` | HTTP(S) endpoint of your S3-compatible service (e.g., `https://s3.us-east-1.amazonaws.com` or `https://s3.us-west-002.backblazeb2.com`) | **Yes** |
| `S3_BUCKET` | Target bucket for all file storage | **Yes** |
| `S3_REGION` | AWS region identifier | No (defaults to `us-east-1`) |
| `S3_ACCESS_KEY_ID` / `S3_SECRET_ACCESS_KEY` | Static IAM credentials | No* |
| `S3_FORCE_PATH_STYLE` | Use path-style URLs (`bucket.endpoint/key`) | No (defaults to `true`) |
| `S3_KEY_PREFIX` | Prefix prepended to every object key for isolation | No |
| `S3_PUBLIC_BASE_URL` | CDN or public-facing base URL for direct access | No |
| `S3_MAX_IMAGE_UPLOAD_BYTES` | Upload size limit in bytes | No (default: 10485760 = 10 MiB) |
| `S3_PRESIGN_TTL_SECONDS` | Expiration for presigned URLs | No (default: 300) |

\*Provide **both** access key variables or **neither**. When omitted, the AWS SDK falls back to its default credential provider chain (IAM instance roles, ECS task roles, etc.).

### Example Production Configuration

```env
S3_ENDPOINT=https://s3.us-east-1.amazonaws.com
S3_BUCKET=kaneo-production-attachments
S3_REGION=us-east-1
S3_ACCESS_KEY_ID=AKIA...
S3_SECRET_ACCESS_KEY=...
S3_KEY_PREFIX=prod/v1
S3_PUBLIC_BASE_URL=https://cdn.example.com/kaneo
S3_MAX_IMAGE_UPLOAD_BYTES=20971520
S3_PRESIGN_TTL_SECONDS=600

```

---

## How Kaneo Generates Presigned Upload URLs

In [`apps/api/src/storage/s3.ts`](https://github.com/usekaneo/kaneo/blob/main/apps/api/src/storage/s3.ts), the `createTaskImageUploadUrl()` function constructs secure, time-limited PUT URLs. This lets clients upload files directly to S3 without streaming through your API servers.

The function builds a namespaced object key from workspace, project, and task identifiers:

```ts
import { createTaskImageUploadUrl } from "@/storage/s3";

const uploadContext = {
  workspaceId: "ws_abc123",
  projectId: "proj_def456",
  taskId: "task_ghi789",
  surface: "description",      // "description" | "comment"
  filename: "architecture.png",
  contentType: "image/png",
};

const { uploadUrl, key, headers } = await createTaskImageUploadUrl(uploadContext);

// Response:
// uploadUrl: "https://s3.us-east-1.amazonaws.com/kaneo-attachments/..."
// key: "prod/v1/workspace/ws_abc123/project/proj_def456/task/task_ghi789/descriptions/uuid-architecture.png"
// headers: { "Content-Type": "image/png", "x-amz-meta-..." }

```

**Key behaviors to understand:**

- **Key prefixing**: If `S3_KEY_PREFIX` is set, it is prepended automatically (e.g., `prod/v1/workspace/...`)
- **UUID injection**: Filenames receive a UUID prefix to prevent collisions
- **Surface isolation**: The `surface` parameter (`description` or `comment`) creates separate subdirectories
- **Validation gates**: `validateTaskAssetUploadInput()` rejects non-image MIME types and oversized files before URL generation
- **Context safety**: `assertTaskImageKeyMatchesContext()` validates that returned keys stay within the authorized workspace-project-task namespace

---

## Retrieving and Deleting Stored Objects

Kaneo provides two access patterns: **private proxied downloads** through the API and **public direct access** when a CDN is configured.

### Private Object Retrieval

Use `getPrivateObject()` in [`apps/api/src/storage/s3.ts`](https://github.com/usekaneo/kaneo/blob/main/apps/api/src/storage/s3.ts) when you need to stream file contents with full control over headers and caching:

```ts
import { getPrivateObject } from "@/storage/s3";

const asset = await getPrivateObject(
  "prod/v1/workspace/ws_abc123/project/proj_def456/task/task_ghi789/descriptions/uuid-architecture.png"
);

// asset.body: Web ReadableStream
// asset.ContentType: "image/png"
// asset.ContentLength: 245760
// asset.ETag: "\"abc123...\""
// asset.LastModified: Date

```

This pattern is exposed through the Kaneo API in [`apps/api/src/index.ts`](https://github.com/usekaneo/kaneo/blob/main/apps/api/src/index.ts) for secure, authenticated downloads where presigned URLs would be inappropriate.

### Object Deletion

Clean up files using `deleteS3Object()`:

```ts
import { deleteS3Object } from "@/storage/s3";

await deleteS3Object(
  "prod/v1/workspace/ws_abc123/project/proj_def456/task/task_ghi789/descriptions/old-architecture.png"
);

```

The cleanup utility at [`apps/api/src/storage/cleanup-assets.ts`](https://github.com/usekaneo/kaneo/blob/main/apps/api/src/storage/cleanup-assets.ts) orchestrates deletions for orphaned assets using this same helper.

---

## Credential Resolution Strategy

The `resolveS3Credentials()` function in [`apps/api/src/storage/s3.ts`](https://github.com/usekaneo/kaneo/blob/main/apps/api/src/storage/s3.ts) implements a strict all-or-nothing policy:

1. **If both** `S3_ACCESS_KEY_ID` and `S3_SECRET_ACCESS_KEY` are present → returns static credentials object
2. **If neither** is present → returns `undefined`, allowing the AWS SDK to use instance metadata service (IMDSv2), ECS task roles, or other chain providers
3. **If only one** is present → throws an explicit configuration error

This design supports both local development (explicit keys) and production deployments on AWS infrastructure (IAM roles) without code changes.

---

## Validation and Security Controls

Kaneo enforces multiple safety layers in the S3 layer:

| Control | Implementation | Location |
|---------|---------------|----------|
| **MIME type whitelist** | Only `image/jpeg`, `image/png`, `image/gif`, `image/webp` allowed | `validateTaskAssetUploadInput()` |
| **Size limits** | Checked against `S3_MAX_IMAGE_UPLOAD_BYTES` | Same |
| **Path traversal prevention** | Regex validation in `assertTaskImageKeyMatchesContext()` | Same |
| **Namespace isolation** | Keys must match `workspace/{id}/project/{id}/task/{id}` pattern | Same |

These checks prevent users from uploading executables, exceeding quotas, or accessing other tenants' data through manipulated keys.

---

## Testing Your S3 Configuration

Verify your setup using the test suite at [`tests/api/storage/s3.test.ts`](https://github.com/usekaneo/kaneo/blob/main/tests/api/storage/s3.test.ts). Key test categories include:

- Configuration parsing with valid and invalid environment combinations
- Presigned URL generation (signature validity, header correctness)
- Key prefix application and path-style URL forcing
- Validation rejection of oversized or disallowed file types

Run tests locally with valid credentials:

```bash
cd apps/api
S3_ENDPOINT=https://s3.us-east-1.amazonaws.com \
S3_BUCKET=test-bucket \
S3_ACCESS_KEY_ID=xxx \
S3_SECRET_ACCESS_KEY=xxx \
npm test -- tests/api/storage/s3.test.ts

```

---

## Integration with Task and Comment Workflows

The S3 storage layer connects to Kaneo's domain logic in two primary locations:

- **[`apps/api/src/task/index.ts`](https://github.com/usekaneo/kaneo/blob/main/apps/api/src/task/index.ts)**: Generates upload URLs when users add images to task descriptions
- **[`apps/api/src/index.ts`](https://github.com/usekaneo/kaneo/blob/main/apps/api/src/index.ts)**: Proxies private downloads through authenticated API endpoints

When `S3_PUBLIC_BASE_URL` is configured, the API can return CDN-facing URLs for direct browser access, reducing origin load for frequently accessed attachments.

---

## Summary

- **Required configuration**: Set `S3_ENDPOINT` and `S3_BUCKET` at minimum; add credentials or rely on IAM roles
- **Core module**: All S3 logic lives in [`apps/api/src/storage/s3.ts`](https://github.com/usekaneo/kaneo/blob/main/apps/api/src/storage/s3.ts) with helpers for upload URLs, downloads, and deletion
- **Security model**: Presigned PUT URLs for uploads, authenticated API proxy for downloads, strict key namespace validation
- **Optional enhancements**: Use `S3_KEY_PREFIX` for multi-tenancy, `S3_PUBLIC_BASE_URL` for CDN integration, and [`cleanup-assets.ts`](https://github.com/usekaneo/kaneo/blob/main/cleanup-assets.ts) for garbage collection

---

## Frequently Asked Questions

### What S3-compatible services work with Kaneo?

Any S3-compatible object store works: AWS S3, Cloudflare R2, Backblaze B2, MinIO, Wasabi, or DigitalOcean Spaces. Configure the appropriate `S3_ENDPOINT` and set `S3_FORCE_PATH_STYLE=true` for providers that don't support virtual-hosted-style buckets.

### How do I use IAM roles instead of static credentials?

Omit both `S3_ACCESS_KEY_ID` and `S3_SECRET_ACCESS_KEY`. The AWS SDK will automatically use the instance metadata service (IMDSv2) on EC2, ECS task roles, or Lambda execution roles. This is the recommended approach for production AWS deployments.

### Why are my uploaded files not appearing in the correct location?

Verify that `S3_KEY_PREFIX` matches your expectations and that the `surface` parameter (`description` vs `comment`) is set correctly in your API calls. Use `assertTaskImageKeyMatchesContext()` in your custom code to validate key construction.

### How can I increase the maximum upload size?

Set `S3_MAX_IMAGE_UPLOAD_BYTES` to your desired limit in bytes. You must also ensure your reverse proxy (nginx, Traefik, etc.) and the Kaneo API server's body parser are configured to accept larger payloads.