Updated Nov 5, 20265 min read

How to Upload Files to Google Cloud Storage from Node.js

Signed upload URLs, direct uploads and signed downloads for a TypeScript API on GCP, with the IAM setup each one needs.

Google Cloud Storage holds the files a backend accepts from users: avatars, invoices, exports. Writing one object from Node.js takes two lines. The parts that slow teams down are around it: signed URLs that fail on Cloud Run because there is no key file, buckets created by hand per environment, and large uploads that hit the request size limit of the API in front of them.

This guide builds an attachments API in TypeScript with three endpoints: one that hands the browser a signed upload URL, one that accepts small files directly, and one that returns a short-lived download link. The first version uses Encore (a tool that works alongside your framework), the second the official @google-cloud/storage client.

Cloud Storage uploads at a glance

Encore@google-cloud/storage
Bucket creationFrom the code, on deploygcloud, Terraform or console
Service permissionsScoped to the bucket, created for youStorage roles you grant
Signed URLs on Cloud RunsignedUploadUrl and signedDownloadUrlNeeds Token Creator role for signBlob
Public filespublic: true with Cloud CDNBucket IAM plus your own CDN setup
Local developmentLocal object store under encore runReal bucket or a third-party emulator

Each option is covered below, starting with Encore.

Declaring a bucket that becomes Cloud Storage with Encore

Encore is a tool that works alongside your framework for TypeScript and Go, with a platform that provisions and deploys into your own AWS or GCP account. A Bucket declared in a service becomes a Cloud Storage bucket on GCP and an S3 bucket on AWS. Cloud access sits with Encore's service identity, not the agent.

Application code passes through Encore and infrastructure guardrails to resources provisioned in your own AWS or GCP account.
Encore provisions the infrastructure your application needs in your own AWS or GCP account.

The whole attachments service:

// uploads/uploads.ts import { api } from "encore.dev/api"; import { Bucket } from "encore.dev/storage/objects"; // A Cloud Storage bucket on GCP, an S3 bucket on AWS. export const attachments = new Bucket("attachments", { versioned: false }); // Returns a short-lived URL the browser can PUT the file to directly. export const uploadUrl = api( { expose: true, method: "POST", path: "/attachments/upload-url" }, async ({ name }: { name: string }): Promise<{ key: string; url: string }> => { const key = `${Date.now()}-${name.replace(/[^\w.-]/g, "_")}`; const { url } = await attachments.signedUploadUrl(key, { ttl: 900 }); return { key, url }; }, ); // Small files can also go through the API. export const upload = api.raw( { expose: true, method: "PUT", path: "/attachments/:key" }, async (req, resp) => { const key = (req.url ?? "").split("/").pop()!; const chunks: Buffer[] = []; for await (const chunk of req) chunks.push(chunk as Buffer); const attrs = await attachments.upload(key, Buffer.concat(chunks), { contentType: req.headers["content-type"] ?? "application/octet-stream", }); resp.writeHead(201, { "Content-Type": "application/json" }); resp.end(JSON.stringify({ key, size: attrs.size })); }, ); export const downloadUrl = api( { expose: true, method: "GET", path: "/attachments/:key/download-url" }, async ({ key }: { key: string }): Promise<{ url: string }> => { const { url } = await attachments.signedDownloadUrl(key, { ttl: 300 }); return { url }; }, );

encore run serves the bucket from a local object store, signed URLs included, so the browser flow works on a laptop:

~/attachments-app
$ encore run$ curl -s -X POST localhost:4000/attachments/upload-url -d '{"name":"hello.txt"}'{"key":"1761728400000-hello.txt","url":"http://127.0.0.1:9800/...&X-Goog-Signature=..."}$ curl -s -X PUT --data-binary 'hello from gcs' "$UPLOAD_URL"$ curl -s -X PUT -H 'Content-Type: text/plain' \ --data-binary 'small payload' localhost:4000/attachments/direct.txt{"key":"direct.txt","size":13}

On deploy, the signed URLs point at Cloud Storage, and there is no key file or Token Creator grant to set up. For files that should be public, such as product images, new Bucket("images", { public: true }) and publicUrl(key) serve them through Cloud CDN. The object storage docs list the rest of the API, including list, remove and attrs.

A coding agent asked to "let users attach files" can add the bucket, the endpoints and the client call in one change, then exercise the upload under encore run.

The deployment guide covers how a push becomes a running service with its bucket.

Get started
brew install encoredev/tap/encore &&
encore app create
Copy

Run in your terminal to get started locally.

The same API with @google-cloud/storage

With the Node.js client, the bucket must exist first and the Cloud Run service account needs roles/storage.objectUser on it. Signing URLs on Cloud Run calls the IAM signBlob API, so the service account also needs roles/iam.serviceAccountTokenCreator on itself:

gcloud storage buckets create gs://acme-attachments-prod \ --location=europe-west1 --uniform-bucket-level-access gcloud storage buckets add-iam-policy-binding gs://acme-attachments-prod \ --member="serviceAccount:attachments@my-project.iam.gserviceaccount.com" \ --role="roles/storage.objectUser" gcloud iam service-accounts add-iam-policy-binding \ attachments@my-project.iam.gserviceaccount.com \ --member="serviceAccount:attachments@my-project.iam.gserviceaccount.com" \ --role="roles/iam.serviceAccountTokenCreator"

The upload and download code uses V4 signed URLs:

import { Storage } from "@google-cloud/storage"; const bucket = new Storage().bucket(process.env.ATTACHMENTS_BUCKET!); export async function uploadUrl(key: string, contentType: string) { const [url] = await bucket.file(key).getSignedUrl({ version: "v4", action: "write", expires: Date.now() + 15 * 60 * 1000, contentType, }); return url; } export async function saveSmallFile(key: string, data: Buffer, contentType: string) { await bucket.file(key).save(data, { contentType }); } export async function downloadUrl(key: string) { const [url] = await bucket.file(key).getSignedUrl({ version: "v4", action: "read", expires: Date.now() + 5 * 60 * 1000, }); return url; }

On Cloud Run, getSignedUrl has no private key to sign with, so the library calls the IAM signBlob API. That only works once the service account holds the Service Account Token Creator role on itself, as described in Google's signing guide. It is the most common reason the code works locally with a key file and fails after deploy.

Good to know

Browser uploads to a signed URL are cross-origin requests, so the bucket's CORS configuration has to allow PUT from your web origin. With a V4 signed URL that includes contentType, the browser must send the same Content-Type header, or Cloud Storage rejects the signature.

How to choose

Pick Encore when you want buckets, signed URLs and service permissions created in your own GCP project from the code, with uploads testable locally under encore run and the same code able to deploy to S3 on AWS.

Pick @google-cloud/storage directly when the bucket already exists and is shared with other systems, or when you need features such as resumable uploads and object lifecycle rules configured from the application.

Frequently asked questions

How do I upload a file to Google Cloud Storage from Node.js?

With @google-cloud/storage you call bucket.file(key).save(buffer) for server-side uploads, or file.getSignedUrl({ version: "v4", action: "write" }) to let a browser upload directly. With Encore, you declare new Bucket("attachments") and call attachments.upload(key, buffer) or attachments.signedUploadUrl(key, { ttl: 900 }). Encore creates the Cloud Storage bucket and the service's permissions in your GCP project on deploy.

Why does getSignedUrl fail on Cloud Run?

Cloud Run has no private key file, so the client signs through the IAM signBlob API. The service account needs the Service Account Token Creator role on itself, otherwise signing fails with a permission error.

Should uploads go through my API or straight to Cloud Storage?

Send large files straight to Cloud Storage with a signed upload URL so they do not pass through your service's memory or request size limit. Small files, like avatars a few hundred kilobytes in size, are fine to accept in the API and upload from there.

Deploy to your cloud on AWS and Google Cloud
without managing infrastructure

Encore lets developers and agents define application infrastructure directly in code, then automatically provisions it from local development to production in your AWS or GCP account.

~/orders — claude
Claude Code
Claude Code v2.1.180Opus 5.5~/orders