This is week 3 of Build with CloudHike, a weekly series where we build a small, real-world app with an AI coding agent and let the agent set up the backend. Disclosure: I built CloudHike, so keep that in mind.
New here? Week 1 built a referral waitlist with Postgres, Auth, Redis and email, and week 2 moved a slow AI call into background jobs with retries and signed webhooks. Each post stands on its own, so you can start right here.
Every app that takes uploads starts the same way: a form, a multipart/form-data POST, and an API route that receives the file and pushes it on to a bucket. It works with a small PNG on your laptop. Then someone uploads a big video, your serverless function runs into its host's request size limit, and every byte that does get through has passed through your server twice: once in, once out.
Then come the other questions. Where do the files live? Who is allowed to see them? How do you share one without making the whole bucket public? Which keys go where, and which ones must never reach the browser?
This week we'll build a small file-sharing and image-upload app where the browser uploads straight to a private CloudHike Storage bucket with presigned URLs, each signed-in user sees only their own files, and sharing works through links that expire. As always, the agent sets up the backend over one MCP connection, and every prompt is below.
What we're building
A "my files" app, like a tiny personal Drive:
- Sign up and sign in with email and password. The session token lives in an HTTP-only cookie.
-
Upload from the browser. The server signs a one-off upload URL, and the browser
PUTs the file straight to the bucket. The file never passes through your server. - Per-user files. Every file is stored under its owner's folder and recorded in Postgres with an owner id, and every route checks ownership.
- Image previews and downloads through presigned links that expire, so the bucket stays private.
- Share links. A file's owner can create a link anyone can open. Your server checks it and hands out a fresh short-lived download URL each time, and the owner can turn it off.
-
Your agent can look inside the bucket with the
list_storage_filestool, without leaving the editor.
Here's the flow for one upload:
- Browser →
POST /api/uploadswith the file's name, type and size → your server checks the session, saves apendingrow and answers with a presignedPUTURL - Browser →
PUTthe file straight to the bucket with that URL - Browser →
POST /api/uploads/:id/complete→ your server checks the object is really there and marks the rowready - The file list asks your server for presigned
GETURLs to show thumbnails and downloads
And here's how each piece maps to CloudHike, all in one project:
-
Storage is a private S3-compatible bucket for the files (
S3_ENDPOINT,S3_REGION,S3_BUCKET,S3_ACCESS_KEY_ID,S3_SECRET_ACCESS_KEY) -
Auth handles sign-up, sign-in and sessions (
AUTH_URL,AUTH_PUBLISHABLE_KEY,AUTH_SECRET_KEY) -
Postgres 16 keeps the file records and share links (
DATABASE_URL)
Normally that's an S3 provider with its own IAM policies and CORS settings, an auth provider and a database host: three accounts and three sets of keys. Here it's one project, one .env and one credit balance. The bucket's access key can only list, read, write and delete in that one bucket, so it can't touch anything else.
What you need
- A CloudHike account. Every new account gets $10 in free credits (eligible accounts, valid for 90 days), plus an extra $5 with the code TUBEGURUJI5, and no card is needed. The Free plan allows 2 projects and includes 500 MB of storage per project, which is plenty for this.
- Cursor or Claude Code (Codex, VS Code, Windsurf and Gemini CLI work too).
- Node.js, for a Next.js app.
Step 1: Connect CloudHike to your agent (once)
If you did week 1 or week 2, you're already connected; skip to Step 2.
In the CloudHike dashboard, open Connect MCP in the sidebar, pick your editor and click Copy with key. That creates your workspace API key and gives you a snippet with the key already filled in. The server is remote (Streamable HTTP), so there's nothing to install.
Claude Code
claude mcp add --transport http --scope user cloudhike https://cloudhike.dev/api/mcp \
--header "Authorization: Bearer icu_your_api_key"
Run /mcp inside Claude Code to check that it's connected.
Cursor (~/.cursor/mcp.json)
{
"mcpServers": {
"cloudhike": {
"url": "https://cloudhike.dev/api/mcp",
"headers": {
"Authorization": "Bearer icu_your_api_key"
}
}
}
}
Open Cursor Settings → MCP and look for the green dot. Snippets for the other editors are in the MCP docs.
Treat that key like a password. It lives in your home folder, not in the repo, and you can revoke it from the same page.
Step 2: Scaffold the app
npx create-next-app@latest file-share
cd file-share
npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner pg
Those two AWS packages are the ones the Storage docs use: CloudHike Storage is S3-compatible, so the standard SDK just works. Open the folder in Cursor, or run claude in it.
Step 3: Let the agent provision the backend
Paste this prompt:
Use the cloudhike MCP tools to set up the backend for this app. It's a file-sharing app where signed-in users upload files from the browser.
Create a CloudHike project called "file-share". Deploy postgres first, then auth and storage. Wait until they're ready. Put the .env into .env.local, make sure it's git-ignored, and don't show me any secret values. Then read the storage, auth and postgres docs with get_docs so you know how to use them. Don't write app code yet. Just tell me which services are running and which variable names are in .env.local.
Behind the scenes, the agent works through CloudHike's MCP tools:
-
create_projectmakes the project (orlist_projectsfinds it if it already exists). -
deploy_servicesdeploys Postgres, Auth and Storage, with the same plan and billing checks as the dashboard. -
wait_for_serviceswaits until each one reports ready. -
get_environmentreturns the project's.env, which the agent writes into.env.local. It can also ask forformat: "names"to list just the variable names with no secrets. -
get_docspulls the Storage, Auth and Postgres pages as Markdown, so the code it writes next follows the real API instead of a guess.
Why Postgres first? Auth keeps its users in your project's Postgres when there is one (in an auth schema), so your users sit next to your file records and there's still nothing new to host. Storage gets a private bucket named proj-<slug> and an access key scoped to that bucket alone.
In editors that show MCP prompts as slash commands, CloudHike also offers a built-in setup_backend prompt that does this step in one go.
Step 4: The tables
Read https://cloudhike.dev/docs/md/postgres. Using node-postgres, add a migration script to this repo that creates two tables:
- files: id (uuid, default gen_random_uuid()), owner_id (text, the Auth user id), object_key (text, unique), file_name, content_type, size_bytes (bigint), status ('pending' or 'ready'), created_at. Add an index on (owner_id, created_at).
- shares: token (text primary key, long and random), file_id (uuid, references files(id) on delete cascade), created_at, revoked_at (nullable). Run the migration with DIRECT_URL if .env.local has it, otherwise DATABASE_URL. Create the app's pool once in lib/db.ts from DATABASE_URL, keep it on globalThis so hot reloads reuse it, and use max 5 connections. Then use the cloudhike describe_table tool to show me both tables.
The bucket holds the bytes; Postgres holds who owns what. That split is what makes "per-user files" something you can check in SQL instead of guessing from folder names. The migration goes through DIRECT_URL because DATABASE_URL goes through a connection pooler (PgBouncer), and the Postgres docs recommend the direct address for migrations.
Same safety model as the earlier weeks: CloudHike's query_database tool always runs one statement in a READ ONLY transaction, execute_sql (for writes) is Pro-only and your editor asks before running it, and no MCP tool can delete a project or service. Schema changes live in your repo as migrations.
Step 5: Sign-in with CloudHike Auth
Read https://cloudhike.dev/docs/md/auth. Add authentication with my CloudHike Auth service: /signup and /login pages (email and password), and server routes that call sign-up/email and sign-in/email with the x-api-key header set to AUTH_PUBLISHABLE_KEY and store the returned token in an HTTP-only cookie. Add lib/session.ts with a getUser() helper that reads the cookie and calls get-session from server code, returning the user or null. Add a sign-out route that calls sign-out and clears the cookie. Protect the home page so signed-out visitors go to /login. Never send AUTH_SECRET_KEY to the browser.
CloudHike Auth is a plain HTTP API: sign-up and sign-in return { token, user }, and you send the token as authorization: Bearer <token> on later calls. The API itself uses no cookies, so the Auth docs recommend storing the token in an HTTP-only cookie that your own server sets, then calling get-session from server code on each request. That keeps the token away from page scripts, and it gives every route in the next steps a trusted user.id to check ownership against.
Want GitHub or Google sign-in too? The Auth card has a shared OAuth app you can switch on, so you don't have to register one yourself.
Step 6: Presigned uploads from the browser
This is the heart of the app.
Read https://cloudhike.dev/docs/md/storage. Add lib/s3.ts following the S3 SDK example in the storage docs: an S3Client from S3_ENDPOINT, S3_REGION, S3_ACCESS_KEY_ID and S3_SECRET_ACCESS_KEY with forcePathStyle: true, the bucket from S3_BUCKET, and uploadUrl(key, contentType) and downloadUrl(key) helpers using getSignedUrl from @aws-sdk/s3-request-presigner. Use expiresIn 600 for uploads and 3600 for downloads. Server code only.
Then add:
- POST /api/uploads: require a signed-in user with getUser(). Take { name, type, size } as JSON. Allow only image/png, image/jpeg, image/webp, image/gif and application/pdf, and at most 10 MB. Build the key as users//-. Insert a files row with status 'pending' and answer { id, url } with a presigned PUT URL for that key and content type.
- POST /api/uploads/[id]/complete: require the owner. Run a HeadObjectCommand on the key. If it's missing, answer 404. If ContentLength is over 10 MB, delete the object with DeleteObjectCommand, delete the row and answer 413. Otherwise save the real size, set status 'ready' and answer the file.
- On the home page, an upload area (a file picker plus drag and drop) that calls /api/uploads, PUTs the file to the returned url with the content-type header set to the file's type, then calls the complete route and refreshes the list. Show an error if any step fails.
This is the pattern the Storage docs describe: create the presigned upload URL on your server and have the browser PUT the file to it. A few details make it safe:
- The secret never leaves your server. The presigned URL is signed with your access key, but it doesn't contain it. It only works for that one key and content type, and only until it expires. The docs say it plainly: do not put the access key in browser code.
-
Your server decides the key. The browser never picks where a file goes. Keys start with
users/<user id>/, so one user can't overwrite another's files, and the random id means two files calledphoto.pngnever collide. -
Trust, then verify. The size the browser sends is only a claim, so the complete route asks the bucket for the real size with
HeadObjectCommandbefore the file counts. Apendingrow that never completes is just an upload that was abandoned. - Plan limits are enforced for you. Per the docs, uploads that would go over your plan's storage limit are refused up front.
One note from the docs: browser uploads need a CORS rule on the bucket, and new buckets get one automatically on AWS. If the browser's PUT ever fails with a CORS error, that's the first thing to check.
Step 7: Per-user lists, previews and share links
Add:
- GET /api/files: require a signed-in user and return only their files with status 'ready', newest first, each with a presigned download URL from downloadUrl(object_key). On the home page, show them as a grid: a thumbnail for images, a file icon for PDFs, the name, size and date, and Download, Share and Delete buttons.
- DELETE /api/files/[id]: require the owner. Delete the object with DeleteObjectCommand, then the row (its shares go with it).
- POST /api/files/[id]/share: require the owner and create a shares row with a random token of at least 32 bytes, base64url. Answer the link APP_URL + "/s/" + token, where APP_URL is a new env variable with the app's public address. Add DELETE on the same route to set revoked_at on that file's shares.
- GET /s/[token]: no sign-in needed. Find the share with revoked_at null and its ready file, then redirect to a fresh presigned download URL with expiresIn 300. If there's no such share, answer 404. Every route that takes a file id must check that files.owner_id matches the signed-in user and answer 404 otherwise, so other people's file ids look the same as missing ones.
The rule from the Storage docs that shapes all of this: buckets are private. You share files with presigned links, which expire, or serve them through your own server. So nothing in the bucket is ever public:
-
Thumbnails and downloads use presigned
GETlinks created per request, for the owner only. -
Share links point at your app, not the bucket.
/s/<token>checks Postgres every time and hands out a link that expires in a few minutes. Turning a share off takes effect on the next click, and a leaked bucket URL stops working on its own. -
Ownership lives in one place. Every query filters by
owner_id, so "see only your own files" is aWHEREclause you can read, not a convention.
Step 8: Let the agent look inside the bucket
Upload a few files, then ask:
Use the cloudhike list_storage_files tool to show me what's in my bucket under users/, with sizes. Then use query_database to count the files rows by status, and tell me whether every 'ready' row has a matching object in the bucket.
list_storage_files lists folders and files in the project's bucket, or searches them, and like query_database it only reads. That makes it a safe way for the agent to check its own work: did the upload land under the right user folder, is the key what the code says it is, and do the bucket and the files table agree?
You can also click Open storage on the Storage card for the dashboard's storage browser. It shows folders and files with size, type and date, previews images, video, audio and PDFs inline, and has Stats with space used against your limit, a breakdown by type and the largest files. Handy for spotting a stray upload or checking you're nowhere near the Free plan's 500 MB.
Step 9: Deploy to Vercel
CloudHike doesn't host apps yet (app hosting is coming soon), so the Next.js app goes on Vercel and the backend stays on CloudHike:
- Push the repo to GitHub. Check that
.env.localis not in the commit. - Import the repo in Vercel.
- Add the variables from
.env.localunder the project's Environment Variables. In the CloudHike dashboard, Environment → Copy as .env gives you the whole file. AddAPP_URLwith your Vercel production address. - Deploy, sign up, and upload a photo.
Because uploads go straight from the browser to the bucket, your Vercel functions only ever handle small JSON requests, whatever the size of the file.
A few production notes from the docs:
- Storage limits. Free includes 500 MB per project. A Free bucket over its limit for 3 days is paused: uploads and new folders are refused, while deleting still works. Pro has no size limit and is billed at $0.08 per GB-month stored.
-
Backups share the bucket. Database backups live in the bucket under
_backups/postgres/and count toward its size, so don't be surprised to see that folder inlist_storage_files. - Keys can be rotated. If a storage secret leaks, Reset keys on the Storage card creates a new access key and secret. Update your env variables on Vercel afterwards. The same goes for Reset keys on the Auth card, and users stay signed in when you do it.
- Auth limits. Free holds up to 10,000 registered users per project, and the first 10,000 monthly active users per project are free on every plan.
-
Keep the database pool small (
max: 5), since each serverless instance opens its own. And a Free database with no connections for 6 hours is stopped until you click Wake on the card; Pro databases are always on. Auth uses that database too, so wake it if sign-in starts answering 503.
What CloudHike handled
Look back at what you didn't do: you didn't open an S3 account, write an IAM policy, set up an auth provider, or copy keys between three dashboards. One MCP connection gave the agent:
- a project with Postgres, Auth and Storage deployed and the
.envwritten without showing secrets in chat, - a private S3-compatible bucket with an access key that only works in that bucket, ready for the standard AWS SDK and presigned URLs,
- sign-up, sign-in and sessions as a plain HTTP API, with users stored in your own Postgres,
- the real docs for each service before writing code,
- read-only tools to check the work:
describe_tableandquery_databasefor the data,list_storage_filesfor the bucket.
Everything shows up in one dashboard and comes out of one credit balance. If you outgrow Free, Pro is $10/month with $10 of usage credits included every month and 10 projects, and Scale is $29.99/month with unlimited projects. The details are on the plans and billing page.
Your turn
The whole build is the five prompts from Step 3 to Step 7, plus the bucket check in Step 8. Run them, then make it yours: allow videos, add folders, or show a "shared with you" page.
👉 Start free at cloudhike.dev. You get $10 in credits at signup, plus $5 more with code TUBEGURUJI5, no card required.
Next week: same backend, any agent. We'll connect Codex, VS Code, Windsurf and Gemini CLI to one CloudHike project, use the built-in setup_backend prompt and get_docs, and look at why no MCP tool can delete anything. Follow along so you don't miss it.
If you try this one, tell me in the comments what your agent built, or where it got stuck. I read every comment.
Top comments (0)