← All guidesWeb Development

Build an Image Upload and Optimization Pipeline with DigitalOcean

Upload private originals directly to DigitalOcean Spaces, create responsive WebP variants with Functions, and deploy the secure workflow on App Platform.

What you will build

This guide builds a deliberately small production-shaped pipeline. The browser never receives a Spaces secret. It asks your App Platform app for a short-lived upload URL, sends the original directly to a private object key, then asks the app to start a secured Function. The Function reads the original, writes 400, 800, and 1600 pixel WebP variants, and returns CDN URLs.

Use it for profile photos, listings, portfolio images, or another modest synchronous workload. It is not a substitute for an account system, malware scanning, or a durable queue. The starter limits uploads to JPEG, PNG, and WebP at 10 MB; add authentication and quotas before accepting untrusted traffic at scale.

  • App Platform hosts the Next.js uploader and keeps secrets server-side.
  • Spaces stores private originals under uploads/ and public derivatives under images/.
  • A secured Node.js Function uses Sharp to process object keys, not image request bodies.
  • The Spaces CDN serves only final variants; private originals are never linked to it.

Start with the architecture and its security boundary

The important boundary is direct-to-storage upload. Sending a full image through an API route wastes application bandwidth and risks request-size limits. Instead, POST metadata to /api/uploads, receive a five-minute presigned PUT URL, and upload directly to Spaces. The response includes an unguessable UUID, not the original filename, so object keys do not disclose user input.

After the PUT succeeds, the browser POSTs that UUID to the same-origin /api/images/:id/process route. That route invokes the Function with its web-auth secret. The Function URL and its X-Require-Whisk-Auth value never reach the browser. Image bytes move only between browser, Spaces, and Function.

Request flow
Browser → POST /api/uploads → App Platform API
Browser → PUT presigned URL → Spaces uploads/<uuid>/original (private)
Browser → POST /api/images/<uuid>/process → App Platform API
App Platform API → secured Function → Spaces images/<uuid>/{400,800,1600}.webp
Browser ← manifest with public Spaces CDN URLs

Create one Space for originals and variants

Create a standard Spaces bucket in the same region as the app. Enable its CDN. Keep file listing disabled. The application writes originals with the private ACL and writes only generated files with the public-read ACL. This gives you one billable store and a simple prefix convention without accidentally exposing the upload path.

Configure the Space’s CORS rule to allow PUT from your deployed App Platform origin only. Allow the PUT method and Content-Type header. Do not use a wildcard origin in production. The exact console screens can evolve, so keep the starter’s README as the source of truth for the values you enter.

  • Bucket: YOUR_BUCKET
  • Private original key: uploads/<uuid>/original
  • Public variant key: images/<uuid>/<width>.webp
  • CDN base: https://YOUR_BUCKET.YOUR_REGION.cdn.digitaloceanspaces.com

Configure the app and Function secrets

Clone the companion starter from examples/digitalocean-image-upload-pipeline, copy .env.example to .env.local, and fill in your Space, region, access key, secret, CDN base URL, Function URL, and a long random Function secret. The access keys live only in App Platform encrypted runtime variables and the Function component configuration; do not prefix them with NEXT_PUBLIC_.

The Function project.yml marks the action as web-accessible but requires the same secret through X-Require-Whisk-Auth. App Platform supports encrypted runtime variables, and Functions can template component variables from project.yml. Give Sharp a remote build: native dependencies must be compiled for the Functions runtime, not your laptop.

Install and run the starter locally
Set-Location examples\digitalocean-image-upload-pipeline
npm install
Copy-Item .env.example .env.local
npm run dev
First test

Verify this workflow before scaling it

Input
Choose one JPEG, PNG, or WebP under 10 MB that you can safely use for a public test.
Build
Request a presigned upload URL, PUT the original to Spaces, then invoke the secured processing route with the returned asset ID.
Expected result
The original exists only under the private uploads/ prefix and the response lists 400, 800, and 1600 pixel WebP URLs under images/.
Stop if
Stop if an original URL opens publicly, the browser can see a Spaces or Function secret, or a non-image/oversized upload is accepted.
Next step
Open one CDN variant in a private window, then add authentication and quotas before accepting real user uploads.

Deploy both components from one App Platform app

Push the starter repository to GitHub, then create an App Platform app from the repository. The .do/app.yaml file declares the Next.js service and the Functions component. Add the values from .env.example as encrypted runtime variables in the control panel before deploying. Do not put secrets in the repository or app spec.

Deploy the Function with the remote-build command from its directory. The app service needs FUNCTION_URL and FUNCTION_AUTH_TOKEN to call it; the Function needs the Spaces variables. Use the URL shown for the deployed Function in App Platform settings. Then update FUNCTION_URL in the app service and redeploy once.

Build native Sharp dependencies remotely
Set-Location functions
doctl serverless deploy . --remote-build

Upload one image and inspect the result

Open the deployed app, choose a JPEG, PNG, or WebP no larger than 10 MB, and upload it. The page first reports that the original reached Spaces, then requests processing and renders the returned variant URLs. Inspect the bucket: uploads/<uuid>/original must remain private, while images/<uuid>/400.webp, 800.webp, and 1600.webp are the only public objects.

Open one returned CDN URL in a private browser window. It should load without a signed query string. If it does not, check the object ACL first, then the CDN endpoint and cache propagation. Do not test access by making uploads/ public; that defeats the design.

Understand the Function’s limits before you scale it

The Function receives a tiny JSON object containing an asset ID; it never receives image bytes. Even so, processing is synchronous. DigitalOcean Functions have configurable memory and timeout limits, a 48 MB built-function limit, and a 1 MB request/response limit. Set memory and timeout high enough for your largest permitted image, then reject inputs that do not fit the tested envelope.

For long-running transformations, high upload volume, video, or retryable work, use a queue and worker pattern instead. Add content verification, rate limiting, authorization, lifecycle deletion, and antivirus scanning before you treat this starter as a user-generated-content service. If variants must be private, replace public CDN URLs with an authorized delivery design; presigned CDN URLs do not receive normal CDN cache benefits.