S3-compatible object storage server with a native binary protocol, written in Go.
Jay provides dual API access: a fully S3-compatible HTTP API and a high-performance native binary protocol for Go clients. It uses bbolt for metadata, atomic file writes with SHA-256 checksums, and includes background integrity scrubbing, garbage collection, and automated backups.
- S3-compatible HTTP API -- works with AWS CLI, SDKs, and any S3 client
- Native binary protocol -- efficient Go client with connection pooling
- Multipart uploads -- S3-compatible chunked uploads up to 10,000 parts
- Presigned URLs -- time-limited delegated access via HMAC-SHA256
- Range requests -- partial object reads (
bytes=0-499, suffix, open-ended) - CopyObject -- server-side copy between buckets
- Bucket policies -- prefix-based allow/deny rules with IP conditions
- Token authentication -- scoped by actions, buckets, and key prefixes
- AWS SigV4 -- simplified mode for AWS CLI compatibility
- Integrity scrubbing -- periodic SHA-256 verification (10% sample/6h)
- Quarantine -- automatic isolation of corrupted objects
- Rate limiting -- per-token token bucket with configurable rate/burst
- TLS -- optional HTTPS for S3 and admin APIs
- Health checks -- liveness and readiness probes
- Metrics -- JSON endpoint with operation counters and byte totals
- Backups -- hourly metadata snapshots with automatic pruning
export JAY_ADMIN_TOKEN=my-secret-admin-token
export JAY_SIGNING_SECRET=my-signing-secret
docker compose up -dgo build -o jay .
JAY_ADMIN_TOKEN=my-secret-admin-token ./jayJay listens on three ports:
:9000-- S3-compatible API:9001-- Admin API + health checks:4444-- Native binary protocol
Jay accepts configuration from environment variables, a YAML config file, or both. When both are provided, env vars win and every conflict is logged at WARN level.
| Variable | Default | Description |
|---|---|---|
JAY_CONFIG_FILE |
(optional) | Path to a YAML config file. Can also be set via --config-file flag (flag takes precedence) |
JAY_DATA_DIR |
./data |
Data directory for objects and metadata |
JAY_LISTEN_ADDR |
:9000 |
S3 API listen address |
JAY_ADMIN_ADDR |
:9001 |
Admin API listen address |
JAY_NATIVE_ADDR |
:4444 |
Native protocol listen address |
JAY_ADMIN_TOKEN |
(required) | Bearer token for admin API (≥ 32 chars) |
JAY_SIGNING_SECRET |
(required) | AES-GCM key for presigned URLs and token secrets (≥ 32 chars) |
JAY_LOG_LEVEL |
info |
Log level: debug, info, warn, error |
JAY_TLS_CERT |
(optional) | Path to TLS certificate file |
JAY_TLS_KEY |
(optional) | Path to TLS private key file |
JAY_RATE_LIMIT |
100 |
Requests/sec per token (0 = disabled) |
JAY_RATE_BURST |
200 |
Rate limit burst size |
JAY_TRUST_PROXY_HEADERS |
false |
Trust X-Forwarded-For / X-Real-IP headers |
JAY_SCRUB_INTERVAL_HOURS |
6 |
Scrubber interval |
JAY_SCRUB_SAMPLE_RATE |
0.1 |
Fraction of objects verified per pass, in (0, 1] |
JAY_SCRUB_BYTES_PER_SEC |
52428800 |
Scrubber read throttle (0 = unlimited) |
JAY_SCRUB_MAX_PER_RUN |
100 |
Max objects visited per bucket per pass |
Point jay at a YAML file via --config-file path/to/config.yml or JAY_CONFIG_FILE=path/to/config.yml:
# config.yml
data_dir: ./data
listen_addr: ":9000"
admin_addr: ":9001"
native_addr: ":4444"
# Secrets can reference env vars via ${VAR} interpolation.
# This lets you commit config.yml to git while keeping secrets in .env.
admin_token: ${JAY_ADMIN_TOKEN}
signing_secret: ${JAY_SIGNING_SECRET}
log_level: info
rate_limit: 100
rate_burst: 200
trust_proxy_headers: false
# Optional ${VAR:-default} syntax provides a fallback.
tls_cert: ${JAY_TLS_CERT:-}
tls_key: ${JAY_TLS_KEY:-}
scrub:
interval_hours: 6
sample_rate: 0.1
bytes_per_sec: 52428800
max_per_run: 100
seed_token:
account: ${JAY_SEED_TOKEN_ACCOUNT:-}
id: ${JAY_SEED_TOKEN_ID:-}
secret: ${JAY_SEED_TOKEN_SECRET:-}Rules:
- Precedence: env var > YAML > hardcoded default. A conflict (both set to different values) logs
WARNat startup but doesn't fail. - Interpolation:
${VAR}and${VAR:-default}are resolved againstos.Getenvon string values only. If neither is set, the value ends up empty (which then triggers the normal secret-length fail-fast if it'sadmin_tokenorsigning_secret). - Mixing sources: perfectly fine to put non-sensitive config in YAML and keep secrets in env vars — interpolation is the bridge.
Convert between YAML and .env or validate a YAML config:
go build -o jay-config ./cmd/jay-config
# YAML → .env (writes to stdout if --output omitted)
jay-config yaml-to-env --input config.yml --output .env
# .env → YAML
jay-config env-to-yaml --input .env --output config.yml
# Validate YAML config (checks required secrets, seed-token atomicity, value ranges)
jay-config validate --input config.yml${VAR} interpolation is preserved literally during conversion — the tool never resolves env vars, only moves keys between formats.
Jay can create an account and token at startup from environment variables, so a fresh deployment doesn't require a manual admin API call before clients can authenticate.
| Variable | Purpose |
|---|---|
JAY_SEED_TOKEN_ACCOUNT |
Name of the account to create (e.g. falco) |
JAY_SEED_TOKEN_ID |
Deterministic token ID used by the client (e.g. falco-native) |
JAY_SEED_TOKEN_SECRET |
Plaintext secret; bcrypt-hashed before storage |
- All three set → Jay creates the account (idempotent by name) and the token (idempotent by ID) with wildcard actions (
"*"). The same creds can be used against either the S3 or the native protocol. - All three empty → seed is skipped. Use this mode if you prefer to bootstrap tokens via the admin API.
- One or two set → Jay refuses to start with an error. This prevents partially-configured deployments.
On every restart, Jay:
- Looks up the account by name; if it exists, reuses its ID.
- Looks up the token by ID. If the stored bcrypt hash matches
JAY_SEED_TOKEN_SECRET, Jay logsseed: token already present, reusingand moves on. - If the hash does NOT match, Jay logs a WARN (
seed: token exists but secret does not match env; refusing to overwrite) and keeps the old secret. Jay never silently overwrites a token.
To rotate the seed secret:
- Either change
JAY_SEED_TOKEN_IDto a new value (the old token stays active; revoke it manually), or - Use the admin API to revoke the old token (
DELETE /_jay/tokens/{id}) first, then set the newJAY_SEED_TOKEN_SECRETand restart.
export JAY_SEED_TOKEN_ACCOUNT=myapp
export JAY_SEED_TOKEN_ID=myapp-primary
export JAY_SEED_TOKEN_SECRET=$(openssl rand -base64 32)
./jayOn first boot you'll see:
seed: account created name=myapp account_id=...
seed: token created token_id=myapp-primary
On second boot:
seed: account exists, reusing
seed: token already present, reusing
# Create account
curl -X POST http://localhost:9001/_jay/accounts \
-H "Authorization: Bearer $JAY_ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "myapp"}'
# Returns: {"account_id": "...", "name": "myapp", ...}
# Create token
curl -X POST http://localhost:9001/_jay/tokens \
-H "Authorization: Bearer $JAY_ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"account_id": "ACCOUNT_ID", "name": "deploy-token"}'
# Returns: {"token_id": "...", "secret": "..."}Bearer token:
Authorization: Bearer <token_id>:<secret>
AWS SigV4 (simplified): Use token_id as the access key and any value as the secret key. Jay validates the token exists but does not verify the HMAC signature.
Tokens can be scoped to specific actions, buckets, and key prefixes:
{
"account_id": "...",
"name": "readonly",
"allowed_actions": ["object:get", "object:list"],
"bucket_scope": ["public-assets"],
"prefix_scope": ["images/"]
}Available actions: bucket:list, bucket:read-meta, bucket:write-meta, object:get, object:put, object:delete, object:list, multipart:create, multipart:upload-part, multipart:complete, multipart:abort.
| Operation | Method | Path |
|---|---|---|
| ListBuckets | GET / |
|
| CreateBucket | PUT /<bucket> |
|
| HeadBucket | HEAD /<bucket> |
|
| DeleteBucket | DELETE /<bucket> |
|
| ListObjectsV2 | GET /<bucket>?list-type=2 |
|
| PutObject | PUT /<bucket>/<key> |
|
| GetObject | GET /<bucket>/<key> |
|
| HeadObject | HEAD /<bucket>/<key> |
|
| DeleteObject | DELETE /<bucket>/<key> |
|
| CopyObject | PUT /<bucket>/<key> |
x-amz-copy-source header |
| CreateMultipartUpload | POST /<bucket>/<key>?uploads |
|
| UploadPart | PUT /<bucket>/<key>?uploadId=X&partNumber=N |
|
| CompleteMultipartUpload | POST /<bucket>/<key>?uploadId=X |
|
| AbortMultipartUpload | DELETE /<bucket>/<key>?uploadId=X |
|
| ListParts | GET /<bucket>/<key>?uploadId=X |
# Configure AWS CLI
aws configure set aws_access_key_id <token_id>
aws configure set aws_secret_access_key <any-value>
aws configure set default.region us-east-1
# Basic operations
aws --endpoint-url http://localhost:9000 s3 mb s3://mybucket
aws --endpoint-url http://localhost:9000 s3 cp file.txt s3://mybucket/
aws --endpoint-url http://localhost:9000 s3 ls s3://mybucket/
aws --endpoint-url http://localhost:9000 s3 cp s3://mybucket/file.txt ./downloaded.txt
aws --endpoint-url http://localhost:9000 s3 sync ./local-dir s3://mybucket/prefix/Jay's native binary protocol uses a compact frame format for high-throughput scenarios.
import "github.com/ivangsm/jay/proto/client"
// Connect
c, err := client.Dial("localhost:4444", tokenID, secret, 4)
if err != nil {
log.Fatal(err)
}
defer c.Close()
// Create bucket
_, err = c.CreateBucket("mybucket")
// Upload object
result, err := c.PutObject("mybucket", "hello.txt",
strings.NewReader("hello world"), 11, nil)
// Download object
obj, err := c.GetObject("mybucket", "hello.txt")
data, _ := io.ReadAll(obj.Body)
obj.Body.Close()
// Multipart upload
uploadID, _ := c.CreateMultipartUpload("mybucket", "large.bin", nil)
etag1, _ := c.UploadPart("mybucket", "large.bin", uploadID, 1, part1Reader, part1Size)
etag2, _ := c.UploadPart("mybucket", "large.bin", uploadID, 2, part2Reader, part2Size)
c.CompleteMultipartUpload("mybucket", "large.bin", uploadID, []client.CompletePart{
{PartNumber: 1, ETag: etag1},
{PartNumber: 2, ETag: etag2},
})
// List objects
list, _ := c.ListObjects("mybucket", &client.ListOptions{Prefix: "photos/"})All endpoints require Authorization: Bearer <JAY_ADMIN_TOKEN>.
| Endpoint | Method | Description |
|---|---|---|
/_jay/accounts |
POST | Create account |
/_jay/tokens |
POST | Create token |
/_jay/tokens |
GET | List tokens |
/_jay/tokens/{id} |
DELETE | Revoke token |
/_jay/metrics |
GET | Server metrics |
/_jay/presign |
POST | Generate presigned URL |
/_jay/quarantine |
GET | List quarantined objects |
/_jay/quarantine/revalidate |
POST | Revalidate quarantined object |
/_jay/quarantine |
DELETE | Purge quarantined objects |
go build -o jay-admin ./cmd/jay-admin
export JAY_ADMIN_TOKEN=my-secret-admin-token
jay-admin create-account -name myapp
jay-admin create-token -account ACCOUNT_ID -name deploy
jay-admin list-tokens
jay-admin revoke-token -id TOKEN_ID
jay-admin metrics
jay-admin presign -bucket mybucket -key file.txt -token-id TOKEN_ID
jay-admin quarantine-list
jay-admin quarantine-purgeGenerate time-limited URLs via the admin API:
curl -X POST http://localhost:9001/_jay/presign \
-H "Authorization: Bearer $JAY_ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"token_id": "TOKEN_ID",
"method": "GET",
"bucket": "mybucket",
"key": "secret-file.txt",
"expires_seconds": 3600
}'The returned URL contains X-Jay-Token, X-Jay-Expires, and X-Jay-Signature query parameters and can be used without any authorization header.
Set JSON policies on buckets to control access by token, prefix, and IP:
{
"version": "2024-01-01",
"statements": [
{
"effect": "allow",
"actions": ["object:get", "object:list"],
"prefixes": ["public/"],
"subjects": ["*"],
"conditions": {
"ip_whitelist": ["10.0.0.0/8"]
}
},
{
"effect": "deny",
"actions": ["*"],
"prefixes": ["secret/"],
"subjects": ["*"]
}
]
}Deny statements always take precedence over allow.
Health checks (on admin port, no auth required):
GET /health/live-- liveness probe (always 200)GET /health/ready-- readiness probe (200 after startup recovery)
Metrics:
curl http://localhost:9001/_jay/metrics \
-H "Authorization: Bearer $JAY_ADMIN_TOKEN"Returns JSON with counters for PutObject, GetObject, DeleteObject, HeadObject, ListObjects, CreateBucket, DeleteBucket, AuthFailures, ChecksumFailures, BytesUploaded, BytesDownloaded, ObjectsQuarantined, and UptimeSeconds.
- Metadata: bbolt embedded key-value store (single-file, ACID)
- Object storage: Atomic writes (temp file, fsync, rename, fsync dir) with 2-level sharded directory layout
- Checksums: SHA-256 computed on every write, verified probabilistically on reads (5% sample)
- Scrubber: Background goroutine checks 10% of objects every 6 hours
- GC: Cleans temp files and empty dirs every 15 minutes
- Backup: Hourly metadata snapshots, keeps 24, prunes after 7 days
- Recovery: On startup, reconciles metadata and physical files, quarantines inconsistencies