Infrastructure Setup
Granite Assets is a pure-Python library — it does not manage infrastructure. This page describes two reference architectures that work well with each backend, and provides enough configuration detail to get a production-quality (or development-quality) environment running.
Local Setup: Nginx + tusd
This architecture is suitable for:
local development environments,
single-server production deployments (internal tools, small scale),
environments where AWS costs or complexity are not justified.
How it works
Two processes run side by side and share the same filesystem directory:
Client
│
├─► POST /files/ ──► tusd ──► writes to /srv/assets/{visibility}/{key}
│ (pre-create hook validates upload-token)
│ (post-finish hook moves file to correct path)
│
└─► GET /assets/… ──► Nginx
├── /assets/public/ → served directly, no token
└── /assets/private/ → requires secure_link token (md5+expires)
Nginx handles all reads. tusd handles all writes via the
tus resumable upload protocol. Your application only needs
to generate signed URLs (download via build_download_url, upload via
build_upload_url); it never touches binary data directly.
Docker Compose (manual / development)
The repository ships a ready-to-use compose file:
# 1. Copy the env template and set both secrets
cp .env.nginx-example .env.nginx
$EDITOR .env.nginx
# 2. Start both services
docker compose -f docker-compose.nginx-manual.yml --env-file .env.nginx up
# 3. Stop
docker compose -f docker-compose.nginx-manual.yml down
SECURE_LINK_SECRET is the Nginx secure_link_md5 secret — Nginx uses it
to validate signed download URLs. UPLOAD_SECRET is the HMAC-SHA256 secret
used by build_upload_url to sign upload tokens. Both must match the values
in your LocalNginxAssetRepositoryConfig.
# Generate strong random secrets (recommended)
python -c "import secrets; print(secrets.token_urlsafe(32))"
Services and ports
Service |
Host port |
Purpose |
|---|---|---|
|
|
Serve files (public + private signed) |
|
|
Accept resumable tus uploads |
Tip
In production, both services typically sit behind a single TLS-terminating
reverse proxy (Nginx itself, Caddy, HAProxy …) on ports 80/443. Configure
tusd to listen on an internal port and proxy /files/ through the
public-facing server.
Repository configuration
import os
from granite_assets import LocalNginxAssetRepositoryConfig, build_asset_repository
config = LocalNginxAssetRepositoryConfig(
storage_path="/srv/assets", # shared with tusd
base_url="http://localhost:8080/assets", # how Nginx exposes files
secure_link_secret=os.environ["SECURE_LINK_SECRET"],
tusd_url="http://localhost:1080",
upload_secret=os.environ["UPLOAD_SECRET"],
upload_ttl_seconds=3600,
secure_link_ttl_seconds=3600,
)
repo = build_asset_repository(config)
Upload flow
# 1. Your API endpoint calls build_upload_url
result = repo.build_upload_url(
"invoices/inv-001.pdf",
"application/pdf",
visibility=AssetVisibility.PRIVATE,
)
# result.url → "http://localhost:1080/files/"
# result.method → "POST"
# result.headers → {"Tus-Resumable": "1.0.0", "Upload-Metadata": "...", ...}
# 2. Return the result to the client (e.g. as JSON from a FastAPI endpoint)
# 3. The client performs the tus upload:
# POST {url} with Upload-Length and the supplied headers → 201 + Location
# PATCH {location} with the file chunks → 204 per chunk
// Browser / Node.js — using the tus-js-client library
import { Upload } from 'tus-js-client';
const upload = new Upload(file, {
endpoint: result.url,
headers: result.headers,
metadata: {}, // tusd already received metadata in the creation POST
onSuccess: () => fetch('/api/confirm', { method: 'POST',
body: JSON.stringify({ key: result.key }) }),
});
upload.start();
Hook integration
tusd calls HTTP hook endpoints at key lifecycle events so your application can validate tokens and post-process completed uploads.
pre-create — validate the upload token before accepting the upload:
import hmac
import base64
from fastapi import Request, HTTPException
UPLOAD_SECRET = os.environ["UPLOAD_SECRET"]
@app.post("/tusd/hooks")
async def tusd_hook(request: Request) -> dict:
body = await request.json()
hook_name = request.headers.get("Hook-Name", "")
upload = body.get("Upload", {})
meta = upload.get("MetaData", {})
if hook_name == "pre-create":
key = meta.get("asset-key", "")
visibility = meta.get("visibility", "private")
content_type = meta.get("content-type", "")
expires = int(meta.get("upload-expires", "0"))
token = meta.get("upload-token", "")
import time
if time.time() > expires:
raise HTTPException(status_code=400, detail="Upload token expired")
payload = f"{expires}:{key}:{visibility}:{content_type}"
expected = hmac.new(
UPLOAD_SECRET.encode(), payload.encode(), "sha256"
).hexdigest()
if not hmac.compare_digest(token, expected):
raise HTTPException(status_code=403, detail="Invalid upload token")
if hook_name == "post-finish":
# Move the completed file to {storage_path}/{visibility}/{key}
# and update your database record.
...
return {}
Configure hooks in docker-compose.nginx-manual.yml:
tusd:
command:
- -upload-dir=/data
- -port=8080
- -hooks-http=http://your-app:8000/tusd/hooks
Nginx configuration
The examples/nginx-secure-link.conf file in the repository is an annotated
operator template. It configures:
/assets/public/— no token required./assets/private/— validates?md5=TOKEN&expires=TIMESTAMPviangx_http_secure_link_module.
The secret placeholder (${SECURE_LINK_SECRET}) is expanded by envsubst
at container startup so secrets never appear in version-controlled files.
AWS Setup: S3 + CloudFront
This architecture is suitable for:
distributed or serverless applications,
high-traffic media delivery,
multi-region deployments.
How it works
Client
│
├─► PUT {presigned S3 URL} ──► S3 (direct upload, bypasses app)
│
└─► GET https://cdn.example.com/… ──► CloudFront ──► S3 (via OAC)
├── /public/* → permanent CF URL, no auth needed
└── /private/* → CloudFront signed URL (time-limited)
CF edge validates signature BEFORE fetching from S3
Origin Access Control (OAC) ensures the S3 bucket is never accessible
directly — all reads go through CloudFront. The bucket policy only grants
s3:GetObject to the CloudFront service principal with the distribution ARN
as condition.
For private assets the application generates a CloudFront signed URL: the edge node verifies the RSA signature before forwarding the request to S3. S3 sees a normal OAC-signed request and serves the object. The private key never leaves your infrastructure.
Download URL modes
S3AssetRepositoryConfig supports three modes for build_download_url.
They are evaluated in priority order:
Priority |
Mode |
URL expires? |
When to use |
|---|---|---|---|
1 (highest) |
CloudFront signed URL — set |
✅ configurable TTL |
Strictest security. Requires a key pair in CloudFront and
|
2 |
Plain CloudFront URL — set |
❌ permanent |
When the CloudFront distribution has no viewer-access restriction and you rely on OAC to keep S3 private. URL never expires — share only with authenticated users at the application layer. |
3 (fallback) |
S3 presigned URL — no CF fields set |
✅ configurable TTL |
Simple setup, no CloudFront key pair needed. Exposes the
|
Terraform
The snippets below create a production-ready setup: S3 bucket, CloudFront distribution with OAC, signing key pair + key group (for private signed URLs), and a Secrets Manager secret for the private key. Adjust names, regions, and tags for your environment.
S3 bucket
# s3.tf
resource "aws_s3_bucket" "assets" {
bucket = var.bucket_name
tags = { Environment = var.environment, ManagedBy = "terraform" }
}
# Block all direct public access — CloudFront uses OAC.
resource "aws_s3_bucket_public_access_block" "assets" {
bucket = aws_s3_bucket.assets.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
# Bucket policy: allow CloudFront OAC to read BOTH prefixes.
#
# Two statements are required:
# AllowCloudFrontPublic — public assets served without a token
# AllowCloudFrontPrivate — private assets; CloudFront validates the signed
# URL at the edge BEFORE issuing this OAC request
#
# The app role needs PutObject / DeleteObject for uploads and deletions.
resource "aws_s3_bucket_policy" "assets" {
bucket = aws_s3_bucket.assets.id
policy = data.aws_iam_policy_document.assets_bucket.json
depends_on = [aws_cloudfront_distribution.assets]
}
data "aws_iam_policy_document" "assets_bucket" {
statement {
sid = "AllowCloudFrontPublic"
principals {
type = "Service"
identifiers = ["cloudfront.amazonaws.com"]
}
actions = ["s3:GetObject"]
resources = ["${aws_s3_bucket.assets.arn}/public/*"]
condition {
test = "StringEquals"
variable = "AWS:SourceArn"
values = [aws_cloudfront_distribution.assets.arn]
}
}
statement {
sid = "AllowCloudFrontPrivate"
principals {
type = "Service"
identifiers = ["cloudfront.amazonaws.com"]
}
actions = ["s3:GetObject"]
# CloudFront verifies the signed URL signature at the edge;
# only then does it issue an OAC-authenticated request to S3.
resources = ["${aws_s3_bucket.assets.arn}/private/*"]
condition {
test = "StringEquals"
variable = "AWS:SourceArn"
values = [aws_cloudfront_distribution.assets.arn]
}
}
statement {
sid = "AllowAppRole"
principals {
type = "AWS"
identifiers = [aws_iam_role.app.arn]
}
actions = ["s3:GetObject", "s3:PutObject", "s3:DeleteObject", "s3:HeadObject"]
resources = ["${aws_s3_bucket.assets.arn}/*"]
}
}
CloudFront distribution
# cloudfront.tf
# ── Origin Access Control ─────────────────────────────────────────────────
resource "aws_cloudfront_origin_access_control" "assets" {
name = "${var.bucket_name}-oac"
description = "OAC for granite-assets bucket"
origin_access_control_origin_type = "s3"
signing_behavior = "always"
signing_protocol = "sigv4"
}
# ── Signing key pair (for private CloudFront signed URLs) ─────────────────
#
# The private key is generated by Terraform, stored in Secrets Manager, and
# NEVER committed to version control.
# The public key is uploaded to CloudFront; AWS stores only the public half.
resource "tls_private_key" "cf_signing" {
algorithm = "RSA"
rsa_bits = 2048
}
resource "aws_cloudfront_public_key" "assets" {
name = "${var.environment}-assets-signing-key"
encoded_key = tls_private_key.cf_signing.public_key_pem
}
resource "aws_cloudfront_key_group" "assets" {
name = "${var.environment}-assets-key-group"
items = [aws_cloudfront_public_key.assets.id]
}
resource "aws_secretsmanager_secret" "cf_private_key" {
name = "${var.environment}/assets/cf-signing-private-key"
recovery_window_in_days = 7
}
resource "aws_secretsmanager_secret_version" "cf_private_key" {
secret_id = aws_secretsmanager_secret.cf_private_key.id
secret_string = tls_private_key.cf_signing.private_key_pem
}
# ── Distribution ─────────────────────────────────────────────────────────
resource "aws_cloudfront_distribution" "assets" {
enabled = true
is_ipv6_enabled = true
origin {
domain_name = aws_s3_bucket.assets.bucket_regional_domain_name
origin_id = "s3-assets"
origin_access_control_id = aws_cloudfront_origin_access_control.assets.id
}
# ── Private prefix: requires a valid CloudFront signed URL ────────────
#
# trusted_key_groups tells CloudFront to validate the ?Signature= and
# ?Key-Pair-Id= query parameters on every request matching this pattern.
# Requests without a valid signature are rejected at the edge (403).
ordered_cache_behavior {
path_pattern = "/private/*"
allowed_methods = ["GET", "HEAD"]
cached_methods = ["GET", "HEAD"]
target_origin_id = "s3-assets"
viewer_protocol_policy = "redirect-to-https"
compress = true
trusted_key_groups = [aws_cloudfront_key_group.assets.id]
forwarded_values {
query_string = false # CF strips auth params before forwarding to S3
cookies { forward = "none" }
}
min_ttl = 0
default_ttl = 0
max_ttl = 0 # never cache private responses at the CDN layer
}
# ── Public prefix: cached aggressively ────────────────────────────────
ordered_cache_behavior {
path_pattern = "/public/*"
allowed_methods = ["GET", "HEAD"]
cached_methods = ["GET", "HEAD"]
target_origin_id = "s3-assets"
viewer_protocol_policy = "redirect-to-https"
compress = true
forwarded_values {
query_string = false
cookies { forward = "none" }
}
min_ttl = 0
default_ttl = 86400 # 1 day
max_ttl = 31536000 # 1 year
}
default_cache_behavior {
allowed_methods = ["GET", "HEAD"]
cached_methods = ["GET", "HEAD"]
target_origin_id = "s3-assets"
viewer_protocol_policy = "redirect-to-https"
forwarded_values {
query_string = false
cookies { forward = "none" }
}
}
restrictions {
geo_restriction { restriction_type = "none" }
}
viewer_certificate {
cloudfront_default_certificate = true
# For a custom domain, replace with:
# acm_certificate_arn = aws_acm_certificate.cdn.arn
# ssl_support_method = "sni-only"
# minimum_protocol_version = "TLSv1.2_2021"
}
tags = { Environment = var.environment, ManagedBy = "terraform" }
}
IAM role for the application
The application role needs:
s3:PutObject/s3:DeleteObject— to upload and delete assets.secretsmanager:GetSecretValue— to fetch the CF private key at startup.cloudfront:CreateInvalidation— optional, to invalidate CDN cache when an asset is updated or deleted.
# iam.tf
resource "aws_iam_role" "app" {
name = "${var.environment}-assets-app"
assume_role_policy = data.aws_iam_policy_document.assume.json
}
data "aws_iam_policy_document" "assume" {
# Adjust to your compute type: lambda.amazonaws.com, ecs-tasks.amazonaws.com, ec2.amazonaws.com …
statement {
actions = ["sts:AssumeRole"]
principals {
type = "Service"
identifiers = ["lambda.amazonaws.com"]
}
}
}
resource "aws_iam_role_policy" "app" {
role = aws_iam_role.app.id
policy = data.aws_iam_policy_document.app.json
}
data "aws_iam_policy_document" "app" {
statement {
actions = ["s3:PutObject", "s3:DeleteObject", "s3:GetObject", "s3:HeadObject"]
resources = ["${aws_s3_bucket.assets.arn}/*"]
}
statement {
actions = ["secretsmanager:GetSecretValue"]
resources = [aws_secretsmanager_secret.cf_private_key.arn]
}
# Optional: CDN cache invalidation when assets change
statement {
actions = ["cloudfront:CreateInvalidation"]
resources = [aws_cloudfront_distribution.assets.arn]
}
}
Outputs
output "cdn_domain" { value = "https://${aws_cloudfront_distribution.assets.domain_name}" }
output "cf_public_key_id" { value = aws_cloudfront_public_key.assets.id }
output "cf_private_key_secret" { value = aws_secretsmanager_secret.cf_private_key.name }
output "distribution_id" { value = aws_cloudfront_distribution.assets.id }
Important
Key pair length. CloudFront requires RSA-2048. Smaller keys are rejected by the API.
Key rotation. To rotate: generate a new tls_private_key, upload a
new aws_cloudfront_public_key, add it to the key group (both keys are
active simultaneously during the rollout), update the secret, restart the
application, then remove the old key from the key group.
Never commit the private key to version control. The Terraform state file contains it — use a remote backend with encryption at rest (e.g. S3 with SSE-KMS + DynamoDB state lock).
Repository configuration
Recommended: private key fetched from Secrets Manager at startup
The application fetches the key once, caches it for the lifetime of the process, and never exposes it in environment variables or logs.
import boto3
import os
from granite_assets import S3AssetRepositoryConfig, build_asset_repository
def _fetch_secret(arn: str, region: str) -> str:
"""Fetch a Secrets Manager secret once and cache the result."""
client = boto3.client("secretsmanager", region_name=region)
return client.get_secret_value(SecretId=arn)["SecretString"]
region = os.environ.get("AWS_REGION", "eu-west-1")
private_key_pem = _fetch_secret(
os.environ["CF_PRIVATE_KEY_SECRET_ARN"], region
)
config = S3AssetRepositoryConfig(
bucket=os.environ["ASSET_BUCKET"],
region=region,
public_base_url=os.environ["CDN_DOMAIN"], # https://d111….cloudfront.net
key_prefix=os.environ.get("ASSET_KEY_PREFIX", ""),
presign_ttl_seconds=3600,
# CloudFront signed URL fields:
cf_key_id=os.environ["CF_KEY_ID"], # KXXXXXXXXXXXXX (from Terraform output)
cf_private_key=private_key_pem,
)
repo = build_asset_repository(config)
Alternative: inline PEM via environment variable
Less secure (the key is visible in the process environment) but simpler for
development or environments without Secrets Manager. Represent newlines as
literal \n when setting the variable:
export CF_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\nMIIE...\n-----END RSA PRIVATE KEY-----"
config = S3AssetRepositoryConfig(
bucket=os.environ["ASSET_BUCKET"],
region=region,
public_base_url=os.environ["CDN_DOMAIN"],
cf_key_id=os.environ["CF_KEY_ID"],
cf_private_key=os.environ["CF_PRIVATE_KEY"].replace("\\n", "\n"),
)
Plain CloudFront URLs (no signing)
If your distribution has no trusted_key_groups restriction and you only
need to hide the S3 domain, set cf_unsigned_urls=True. The URL is
permanent — enforce access control at the application layer.
config = S3AssetRepositoryConfig(
bucket=os.environ["ASSET_BUCKET"],
region=region,
public_base_url=os.environ["CDN_DOMAIN"],
cf_unsigned_urls=True, # plain https://d111….cloudfront.net/private/…
)
Upload flow
# 1. Generate a presigned PUT URL
result = repo.build_upload_url(
"invoices/inv-001.pdf",
"application/pdf",
ttl_seconds=600,
)
# result.url → "https://my-bucket.s3.eu-west-1.amazonaws.com/…?X-Amz-…"
# result.method → "PUT"
# result.headers → {"Content-Type": "application/pdf"}
# 2. Return the result JSON to the client
# 3. Client PUTs the file directly to S3 — no server bandwidth used
await fetch(result.url, {
method: result.method,
headers: result.headers,
body: fileBlob,
});
// Notify backend that upload is complete
await fetch('/api/confirm', { method: 'POST',
body: JSON.stringify({ key: result.key }) });
Download flow
# Public asset — permanent CloudFront URL, no signature needed
url = repo.build_public_url("public/avatars/user-42.jpg")
print(url.url) # https://d111….cloudfront.net/public/avatars/user-42.jpg
print(url.is_permanent) # True
# Private asset — CloudFront signed URL (time-limited)
url = repo.build_download_url("private/invoices/inv-001.pdf", ttl_seconds=300)
print(url.url) # https://d111….cloudfront.net/private/…?Expires=…&Signature=…
print(url.is_permanent) # False
print(url.expires_at) # datetime(…, tzinfo=UTC)
Note
CloudFront strips the ?Expires, ?Signature, and ?Key-Pair-Id
query parameters before forwarding the request to S3 via OAC. S3 never
sees the auth parameters — it only sees a valid OAC-signed request.
Environment variables reference
Variable |
Description |
Example |
|---|---|---|
|
S3 bucket name |
|
|
AWS region |
|
|
CloudFront distribution domain |
|
|
CloudFront public key ID |
|
|
Secrets Manager ARN of the private key |
|
|
Inline PEM (alternative to secret ARN) |
|
|
S3 key prefix (e.g. |
|
|
Default TTL for signed/presigned URLs |
|
Troubleshooting
403 Access Denied — ``server: AmazonS3`` in response headers
The S3 bucket policy does not allow the CloudFront OAC to read the requested path. Check that both
AllowCloudFrontPublicandAllowCloudFrontPrivatestatements are present in the bucket policy, and that the condition ARN matches your distribution.
403 Access Denied — ``x-cache: Error from cloudfront`` but no S3 header
CloudFront rejected the request before it reached S3. Most likely causes:
The URL has expired (
Expirestimestamp in the past).The signature is invalid — the
cf_key_idused to sign does not belong to thetrusted_key_groupsconfigured on the cache behavior.The cache behavior for
/private/*is missingtrusted_key_groups.
403 Access Denied — ``TrustedKeyGroupDoesNotExist`` when updating distribution
The key group ID passed to
trusted_key_groupsdoes not exist. Runaws cloudfront list-key-groupsto get the correct ID.
Signed URL works with ``key_prefix=””`` but 403 with the real prefix
The URL path must match the cache behavior pattern. If
key_prefix="assets"the generated URL is/assets/private/…— make sure the CloudFrontordered_cache_behaviorusespath_pattern = "/assets/private/*"(not just/private/*).
LocalNginx + tusd |
S3 + CloudFront |
|
|---|---|---|
Upload protocol |
tus (POST + PATCH) |
Presigned PUT |
Download protocol |
HTTP (Nginx) |
HTTPS (CloudFront / S3) |
Resumable uploads |
Yes (tus protocol) |
No (single-part PUT) |
Large files |
Excellent (tus chunking) |
Up to 5 GB per PUT |
Scalability |
Single host |
Unlimited |
Cost |
Infrastructure only |
Pay-per-request + storage |
Ops complexity |
Low (two containers) |
Medium (IAM, Terraform) |
API identical? |
Yes |
Yes |
The Python API — build_upload_url, build_download_url, save, etc. —
is identical regardless of which backend you choose. Switching from local
to S3 only requires replacing the configuration dataclass.