Skip to main content

S3 Object Storage

Category: Backend & APIs

Get this pack →

This page is generated from the Air Pipe marketplace. Browse it live to install into your organization.

Private file storage on S3-compatible object storage, where the file never passes through Air Pipe. Air Pipe holds the metadata and mints short-lived presigned URLs; the bytes go client ↔ S3 directly.

Works with anything S3-compatible — AWS S3, Cloudflare R2, MinIO, Backblaze B2. Only S3_ENDPOINT and S3_REGION change.

Requires an Air Pipe engine with operation: put on s3_generate_presigned_url (added in 1.37.0). Earlier engines can only presign downloads.

Why presigned URLs rather than proxying the upload

Routing uploads through your API means every large file is bounded by your request timeout, your memory, and your bandwidth bill. Presigning avoids all three:

  • No size ceiling — a 2 GB upload never touches Air Pipe.
  • No proxy cost or latency — the client talks to S3 directly.
  • The bucket stays private — no public objects. Every download is a fresh link that expires (5 minutes here), so a leaked URL stops working.
  • Signatures are method-scoped — a download link cannot be replayed as an upload.

Endpoints

MethodRoutePurpose
POST/files/upload-urlRecord a pending object, return a presigned PUT (15 min).
POST/files/confirmMark the object stored once the client's upload succeeded.
GET/files/downloadReturn a presigned GET (5 min) for a stored object.
GET/filesList stored objects, newest first (?owner=, ?limit=).
POST/api/seedCreate the s3_objects table (from seed.yml).

Setup

  1. Create a bucket and an access key with read/write on it. Keep the bucket private — this pack works by minting links, not by public objects.

  2. Set the managed variables:

    • DATABASE_URL — Postgres connection string
    • S3_BUCKET, S3_ENDPOINT, S3_REGION, S3_ACCESS_KEY_ID, S3_SECRET_KEY

    For Cloudflare R2, S3_ENDPOINT is https://<account-id>.r2.cloudflarestorage.com and S3_REGION is auto.

  3. Seed the table: POST /api/seed.

  4. Set CORS on the bucket to allow PUT from your web origin, or the browser upload in step 2 below is blocked before it starts.

The upload flow

# 1. ask for an upload URL
curl -s -X POST https://<your-host>/files/upload-url \
-H 'content-type: application/json' \
-d '{"filename":"report.pdf","content_type":"application/pdf","owner":"alice"}'
{ "file_id": "019f97…", "upload_url": "https://…X-Amz-Signature=…", "expires_in": 900 }
# 2. PUT the bytes STRAIGHT to S3 — this request does not go to Air Pipe
curl -s -X PUT "<upload_url>" \
-H 'content-type: application/pdf' \
--data-binary @report.pdf

# 3. confirm, so the object flips from pending to stored
curl -s -X POST https://<your-host>/files/confirm \
-H 'content-type: application/json' \
-d '{"file_id":"019f97…","size_bytes":12345}'

Downloading

curl -s "https://<your-host>/files/download?file_id=019f97…"
{ "file_id": "019f97…", "filename": "report.pdf", "download_url": "https://…", "expires_in": 300 }

The link is valid for 5 minutes. Mint a new one per request rather than storing them.

How it works

  1. upload-url — validates the filename, generates a uuid and namespaces the object key as <uuid>/<filename> so two uploads of the same name can never collide, inserts the row as pending, then presigns a PUT.
  2. confirm — flips pendingstored and records the size. The UPDATE … WHERE status = 'pending' plus a [0].id is_not_null assert means confirming an unknown or already- confirmed id is a clean 404, not a silent no-op.
  3. download — looks the object up (must be stored), presigns a GET.
  4. files — lists stored objects; ?owner= is applied only when non-empty, so the same query serves both filtered and unfiltered listings.

Housekeeping

Rows left pending are uploads that were never completed — the URL expired, or the client went away. They hold no bytes. Sweep them on a schedule:

DELETE FROM s3_objects WHERE status = 'pending' AND created_at < NOW() - INTERVAL '1 day';

The Scheduled Cleanup pack shows that pattern with a schedule: block.

Tuning the TTLs

upload_ttl_secs (900) and download_ttl_secs (300) are in global.variables. The upload window only needs to cover starting the transfer, and a download link should be short — long enough to click, short enough that sharing it is useless.

Configuration

config.yml

name: S3ObjectStorage

docs: true

# Private file storage on S3-compatible object storage, with the file itself never
# passing through Air Pipe.
#
# The pattern is presigned URLs on both sides:
# 1. the client asks for an upload URL — Air Pipe records the pending object and
# mints a short-lived presigned PUT
# 2. the client PUTs the bytes STRAIGHT to S3 (no size limit, no proxying, no
# request-timeout risk on large files)
# 3. the client confirms, and Air Pipe marks the object stored
# 4. downloads are short-lived presigned GETs minted per request, so the bucket
# itself stays private and links expire
#
# Works with anything S3-compatible: AWS S3, Cloudflare R2, MinIO, Backblaze B2 —
# only `endpoint` and `region` change.
#
# Required managed variables:
# DATABASE_URL — Postgres connection string (object metadata)
# S3_BUCKET — bucket name
# S3_ENDPOINT — e.g. https://s3.eu-west-1.amazonaws.com
# or https://<account>.r2.cloudflarestorage.com for R2
# S3_REGION — e.g. eu-west-1 (use "auto" for R2)
# S3_ACCESS_KEY_ID — access key
# S3_SECRET_KEY — secret key

global:
variables:
upload_ttl_secs: 900 # 15 minutes to start the upload
download_ttl_secs: 300 # 5 minutes for a download link

databases:
main:
driver: postgres
conn_string: "a|ap_var::DATABASE_URL|"

interfaces:

# POST /files/upload-url
# Body: { "filename": "report.pdf", "content_type": "application/pdf", "owner": "alice" }
# Returns a presigned PUT the client uploads to directly.
files/upload-url:
output: http
method: POST
summary: Mint a presigned upload URL and record the pending object
description: >
Records the object as pending and returns a short-lived presigned PUT URL.
The client uploads straight to S3, so the file never transits Air Pipe.
tags: [s3, storage, upload, presigned]
request_example: |-
{ "filename": "report.pdf", "content_type": "application/pdf", "owner": "alice" }
response_example: |-
{ "file_id": "019f97…", "upload_url": "https://…X-Amz-Signature=…", "expires_in": 900 }

actions:
- name: ValidateBody
input: a|body|
hide_data_on_success: true
assert:
http_code_on_error: 400
error_message: "filename is required"
tests:
- value: filename
is_not_null: true
is_not_empty: true

# The object key is namespaced by a fresh uuid so two uploads of the same
# filename can never collide or overwrite each other.
- name: NewObject
run_when_succeeded: [ValidateBody]
json_output: |-
{ "ready": true }
post_transforms:
- add_attribute:
file_id: a|uuid|

- name: Record
run_when_succeeded: [NewObject]
database: main
query: |-
INSERT INTO s3_objects (id, object_key, filename, content_type, owner, status)
VALUES (
a|NewObject::file_id->single_quote|,
a|NewObject::file_id->single_quote| || '/' || a|ValidateBody::filename->single_quote|,
a|ValidateBody::filename->single_quote|,
a|ValidateBody::content_type->default('application/octet-stream')->single_quote|,
a|ValidateBody::owner->default('')->single_quote|,
'pending'
)
RETURNING id, object_key
post_transforms:
- extract_value: "[0]"

# operation: put is what makes browser-direct upload possible (engine 1.37.0+).
- name: Presign
run_when_succeeded: [Record]
json_output: |-
{ "file_id": "a|Record::id|", "expires_in": a|var::upload_ttl_secs| }
post_transforms:
- s3_generate_presigned_url:
operation: put
bucket: a|ap_var::S3_BUCKET|
file_key: a|Record::object_key|
expiration_secs: 900
endpoint: a|ap_var::S3_ENDPOINT|
region: a|ap_var::S3_REGION|
access_key_id: a|ap_var::S3_ACCESS_KEY_ID|
secret_access_key: a|ap_var::S3_SECRET_KEY|
key: upload_url

# POST /files/confirm
# Called once the client's PUT succeeds. Body: { "file_id": "…", "size_bytes": 12345 }
files/confirm:
output: http
method: POST
summary: Mark an uploaded object as stored
description: Flips a pending object to stored once the client's direct upload finished.
tags: [s3, storage, upload]
request_example: |-
{ "file_id": "019f97…", "size_bytes": 12345 }

actions:
- name: ValidateBody
input: a|body|
hide_data_on_success: true
assert:
http_code_on_error: 400
error_message: "file_id is required"
tests:
- value: file_id
is_not_null: true
is_not_empty: true

- name: MarkStored
run_when_succeeded: [ValidateBody]
database: main
query: |-
UPDATE s3_objects
SET status = 'stored',
size_bytes = a|ValidateBody::size_bytes->default(0)|,
uploaded_at = NOW()
WHERE id = a|ValidateBody::file_id->single_quote|
AND status = 'pending'
RETURNING id, filename, size_bytes
assert:
http_code_on_error: 404
error_message: "no pending object with that file_id"
tests:
- value: "[0].id"
is_not_null: true
post_transforms:
- extract_value: "[0]"

- name: Respond
run_when_succeeded: [MarkStored]
json_output: |-
{ "file_id": "a|MarkStored::id|", "filename": "a|MarkStored::filename|", "status": "stored" }

# GET /files/download?file_id=…
# Mints a short-lived presigned GET. The bucket stays private.
files/download:
output: http
method: GET
summary: Mint a short-lived presigned download URL
description: >
Returns a presigned GET for a stored object. Links expire, so the bucket
itself is never public.
tags: [s3, storage, download, presigned]

actions:
- name: ReadParams
input: a|params|
hide_data_on_success: true

- name: Lookup
run_when_succeeded: [ReadParams]
database: main
query: |-
SELECT id, object_key, filename, content_type
FROM s3_objects
WHERE id = a|ReadParams::file_id->default('')->single_quote|
AND status = 'stored'
assert:
http_code_on_error: 404
error_message: "no stored object with that file_id"
tests:
- value: "[0].id"
is_not_null: true
post_transforms:
- extract_value: "[0]"

- name: Presign
run_when_succeeded: [Lookup]
json_output: |-
{
"file_id": "a|Lookup::id|",
"filename": "a|Lookup::filename|",
"expires_in": a|var::download_ttl_secs|
}
post_transforms:
- s3_generate_presigned_url:
operation: get
bucket: a|ap_var::S3_BUCKET|
file_key: a|Lookup::object_key|
expiration_secs: 300
endpoint: a|ap_var::S3_ENDPOINT|
region: a|ap_var::S3_REGION|
access_key_id: a|ap_var::S3_ACCESS_KEY_ID|
secret_access_key: a|ap_var::S3_SECRET_KEY|
key: download_url

# GET /files?owner=alice&limit=25
files:
output: http
method: GET
summary: List stored objects
description: Lists stored objects, newest first, optionally filtered by owner.
tags: [s3, storage]

actions:
- name: ReadParams
input: a|params|
hide_data_on_success: true

- name: ListFiles
run_when_succeeded: [ReadParams]
database: main
query: |-
SELECT id, filename, content_type, owner, size_bytes, status, uploaded_at, created_at
FROM s3_objects
WHERE status = 'stored'
AND (a|ReadParams::owner->default('')->single_quote| = ''
OR owner = a|ReadParams::owner->default('')->single_quote|)
ORDER BY created_at DESC
LIMIT a|ReadParams::limit->default(25)|

seed.yml

name: S3ObjectStorageSeed
description: Creates the s3_objects table (idempotent). Safe to re-run — resets to empty.

docs: true

# Required managed variables:
# DATABASE_URL — Postgres connection string

global:
databases:
main:
driver: postgres
conn_string: "a|ap_var::DATABASE_URL|"

interfaces:

# POST /api/seed
api/seed:
output: http
method: POST

actions:
- name: CreateTable
database: main
hide_data_on_success: true
query: |
CREATE TABLE IF NOT EXISTS s3_objects (
id TEXT PRIMARY KEY,
object_key TEXT NOT NULL UNIQUE,
filename TEXT NOT NULL,
content_type TEXT NOT NULL DEFAULT 'application/octet-stream',
owner TEXT NOT NULL DEFAULT '',
size_bytes BIGINT NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'pending',
uploaded_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

- name: CreateIndexes
database: main
run_when_succeeded: [CreateTable]
hide_data_on_success: true
query: |
CREATE INDEX IF NOT EXISTS idx_s3_objects_created_at ON s3_objects (created_at DESC);

- name: Reset
database: main
run_when_succeeded: [CreateIndexes]
hide_data_on_success: true
query: "TRUNCATE TABLE s3_objects;"

- name: Respond
run_when_succeeded: [Reset]
json_output: |-
{ "status": "seeded" }

schema.sql

-- Object metadata. The bytes live in S3; only the record lives here.
CREATE TABLE IF NOT EXISTS s3_objects (
id TEXT PRIMARY KEY,
object_key TEXT NOT NULL UNIQUE,
filename TEXT NOT NULL,
content_type TEXT NOT NULL DEFAULT 'application/octet-stream',
owner TEXT NOT NULL DEFAULT '',
size_bytes BIGINT NOT NULL DEFAULT 0,
-- pending: upload URL minted, bytes not confirmed yet. stored: upload confirmed.
status TEXT NOT NULL DEFAULT 'pending',
uploaded_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX IF NOT EXISTS idx_s3_objects_created_at ON s3_objects (created_at DESC);
CREATE INDEX IF NOT EXISTS idx_s3_objects_owner ON s3_objects (owner);
CREATE INDEX IF NOT EXISTS idx_s3_objects_status ON s3_objects (status);