Quick experience
This guide walks you through the full Postgres-Native cloud storage loop in 5 minutes: create a bucket → write policies → upload → download → intentionally trigger an authorization failure to verify RLS.
- A PostgreSQL database environment (i.e. Postgres-Native mode); see Postgres-Native overview
- Console or SQL client access to the environment's PostgreSQL instance
- Basic familiarity with Authentication: the three roles (
anon/authenticated/service_role) andauth.uid()
Step 1: Create a bucket
The following examples create an avatars bucket for user avatars. Choose CLI, manager-node SDK, JS SDK, HTTP API, or SQL depending on your scenario:
- CLI
- manager-node SDK
- JS SDK
- HTTP API
- SQL
Use CloudBase CLI to execute PostgreSQL SQL and create the bucket:
tcb db execute -e <envId> --sql "INSERT INTO storage.buckets (id, name, public, file_size_limit, allowed_mime_types) VALUES ('avatars', 'avatars', false, 5 * 1024 * 1024, ARRAY['image/png', 'image/jpeg', 'image/webp']);"
Use @cloudbase/manager-node in a server-side script. The example uses a server-side administrator credential as accessToken:
import CloudBase from '@cloudbase/manager-node';
const app = new CloudBase({
secretId: process.env.TENCENTCLOUD_SECRET_ID,
secretKey: process.env.TENCENTCLOUD_SECRET_KEY,
envId: '<env-id>',
});
const result = await app.storage.createBucket({
id: 'avatars',
name: 'avatars',
public: false,
fileSizeLimit: 5 * 1024 * 1024,
allowedMimeTypes: ['image/png', 'image/jpeg', 'image/webp'],
accessToken: '<apiKey-or-access-token>',
});
console.log(result.name);
With a login state that has bucket creation permission, use app.storage.createBucket():
import cloudbase from '@cloudbase/js-sdk';
const app = cloudbase.init({ env: '<env-id>' });
const { data, error } = await app.storage.createBucket('avatars', {
public: false,
type: 'STANDARD',
fileSizeLimit: 5 * 1024 * 1024,
allowedMimeTypes: ['image/png', 'image/jpeg', 'image/webp'],
});
if (error) {
throw error;
}
console.log(data.name);
If you need to create a bucket from another language or backend service, call the Postgres-Native Cloud Storage HTTP API:
curl -L 'https://<env-id>.api.tcloudbasegateway.com/v1/storages/bucket/' \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer <TOKEN>' \
-d '{
"id": "avatars",
"name": "avatars",
"public": false,
"file_size_limit": 5242880,
"allowed_mime_types": ["image/png", "image/jpeg", "image/webp"]
}'
For the full API reference, see Create Bucket.
In PostgreSQL, insert a row into storage.buckets to create a bucket:
INSERT INTO storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
VALUES (
'avatars',
'avatars',
false, -- not public; access is decided by RLS
5 * 1024 * 1024, -- 5 MB per file
ARRAY['image/png', 'image/jpeg', 'image/webp']
);
Main columns of storage.buckets:
| Column | Type | Description |
|---|---|---|
id | text | Bucket identifier (primary key) |
name | text | Bucket name, length ≤ 100 |
public | boolean | Public flag (default false). Does not bypass RLS automatically — still need an explicit policy. |
file_size_limit | bigint | Per-file size limit (bytes) |
allowed_mime_types | text[] | Allowed MIME-type whitelist |
owner_id | text | User ID of the creator |
Step 2: Write RLS policies
Object keys follow the convention <uid>/<filename> — "one folder per user". We want:
- A signed-in user can upload to their own folder
- A signed-in user can read / update / delete files under their own folder
- All other access is denied
-- Anyone may read bucket metadata (e.g. for the front-end to enumerate buckets; optional)
CREATE POLICY buckets_select_all ON storage.buckets
FOR SELECT TO anon, authenticated
USING (true);
-- ↓↓↓ Four policies on storage.objects ↓↓↓
-- Only the owner can read
CREATE POLICY avatars_select_own ON storage.objects
FOR SELECT TO authenticated
USING (
bucket_id = 'avatars'
AND (storage.foldername(name))[1] = auth.uid()
);
-- Only signed-in users can upload, and only to their own folder
CREATE POLICY avatars_insert_own ON storage.objects
FOR INSERT TO authenticated
WITH CHECK (
bucket_id = 'avatars'
AND (storage.foldername(name))[1] = auth.uid()
);
-- Only the owner can overwrite or update metadata
CREATE POLICY avatars_update_own ON storage.objects
FOR UPDATE TO authenticated
USING (
bucket_id = 'avatars'
AND (storage.foldername(name))[1] = auth.uid()
)
WITH CHECK (
bucket_id = 'avatars'
AND (storage.foldername(name))[1] = auth.uid()
);
-- Only the owner can delete
CREATE POLICY avatars_delete_own ON storage.objects
FOR DELETE TO authenticated
USING (
bucket_id = 'avatars'
AND (storage.foldername(name))[1] = auth.uid()
);
storage.foldername(name)is a built-in helper that turns'<uid>/avatar.png'into{'<uid>'}.(storage.foldername(name))[1]gets the first folder segment and compares it withauth.uid().auth.uid()returns thesubof the current JWT — see Authentication: reading login state in SQL.- You do not need to
GRANTanything onstorage.objects. All three roles are already grantedALLon it; the only gate is RLS.
Step 3: Sign in and upload from the client
Use app.storage.from(bucketId) in @cloudbase/js-sdk to enter native Postgres-Native bucket object operations:
import cloudbase from '@cloudbase/js-sdk';
const app = cloudbase.init({ env: '<env-id>' });
const auth = app.auth;
// 1. Sign in (anonymously is fine — anonymous sign-in still gets a real sub)
const { data: loginState } = await auth.signInAnonymously();
const uid = loginState?.user?.id;
// 2. Enter the avatars bucket. Paths below are object names inside the bucket; do not pass cloud:// fileIDs
const bucket = app.storage.from('avatars');
// 3. Upload to your own folder
const { error } = await bucket.upload(`${uid}/avatar.png`, file, {
contentType: file.type || 'image/png',
upsert: true,
});
if (error) {
throw error;
}
// 4. Create a signed download URL
const { data: signed } = await bucket.createSignedUrl(`${uid}/avatar.png`, 600);
console.log(signed.fullSignedURL);
Step 4: Inspect the metadata via SQL
Run the following in the SQL console:
SELECT id, bucket_id, name, owner_id, metadata, created_at
FROM storage.objects
WHERE bucket_id = 'avatars'
ORDER BY created_at DESC
LIMIT 5;
You should see the row you just uploaded; owner_id is filled with the current user's sub.
Step 5: Verify RLS by intentionally violating it
Open a new browser window (or sign out), sign in anonymously again to get a different uid, and try:
const bucket = app.storage.from('avatars');
// Try to write into someone else's folder — must fail
await bucket.upload(`<other-user-uid>/hack.png`, file);
// → Throws: insufficient privilege (violates the WITH CHECK of avatars_insert_own)
await bucket.createSignedUrl(`<other-user-uid>/avatar.png`, 600);
// → Throws or returns empty: the SELECT policy filters out the row.
You have just seen RLS in action: it is the sole gate, and it is fully transparent to the client — no special handling on the front end, the database simply denies unauthorized requests.
Troubleshooting cheat sheet
| Symptom | Likely cause |
|---|---|
| Upload returns 403 | The WITH CHECK of the INSERT policy was not satisfied; check that the first segment of the object name equals the current uid. |
createSignedUrl throws or returns nothing | The SELECT policy filtered the row out; cross-check by querying with service_role in the SQL console. |
DELETE FROM storage.objects errors out | Direct deletion is forbidden by the protect_delete trigger. Always go through the SDK / Storage API. |
| Upload fails for files > 5 MB | buckets.file_size_limit is too small — raise it or use a different bucket. |
| Non-image upload fails | buckets.allowed_mime_types does not allow it — adjust the whitelist. |
See FAQ and RLS policy patterns for more.
Next steps
- Bucket Management — understand bucket fields, public / private access model, and upload limits
- Upload Files — learn more about
contentType,metadata,upsert, and path design - Access and Download Files — downloads, signed URLs, and public URLs
- Permission management — full picture of the
storageschema and the RLS-only model - RLS policy patterns — 8 copy-pasteable templates (public / personal / team / metadata-driven, ...)