Build an image upload and optimization pipeline
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 starter uploads a private original and creates public WebP variants. 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.
For the first check, use an image at least 1600 pixels wide after orientation, under 10 MB. The processor does not upscale: smaller originals can produce differently named files with the same physical width. Inspect the output dimensions as well as the three URLs.
- 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.
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 URLsCreate 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 and inspect .env.example to prepare the encrypted deployment variables. The local server below is optional. To test processing locally, first deploy the Function in the next section, then copy its URL and the other values into .env.local. 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. App Platform builds Sharp for the Functions runtime during deployment.
git clone https://github.com/TylorMayfield/digitalocean-image-upload-pipeline.gitSet-Location digitalocean-image-upload-pipelinegit checkout 5b8eafad6e2ec0f0ea554af270e349717d1b182fnpm ciCopy-Item .env.example .env.localnpm run devThe local server starts after you configure .env.local.
Deploy both components from one App Platform app
Use App Platform to deploy both components from GitHub. This path works in the browser and does not require doctl. App Platform builds the Functions component remotely, including Sharp's native dependencies.
The web service and Function both need SPACES_BUCKET, SPACES_REGION, SPACES_KEY, SPACES_SECRET, and SPACES_CDN_BASE_URL. Set the same long FUNCTION_AUTH_TOKEN in both components. Only the web service needs FUNCTION_URL. Keep these values in encrypted dashboard variables.
Companion revision 5b8eafa contains the web service and the Functions action with index.js and its dependencies in functions/packages/image/process/. Check that structure in your copy before deployment. Build and image-processing tests pass; deployment with your credentials still needs verification.
- Create your own copy of the companion repository on GitHub. In .do/app.yaml, replace both REPLACE_WITH_YOUR_GITHUB_REPOSITORY values with your owner/repository name, then commit the file.
- In App Platform, create an app from that repository and review its app spec. Confirm it contains the web service with source / and image-functions with source /functions. If Functions is missing, add that component from the same repository before continuing.
- Add the encrypted component variables listed above. Leave FUNCTION_URL unset on the web service for the first deployment. The page can start, but image processing remains unavailable until the next step.
- Deploy and check the image-functions build logs. Once image/process is available, copy its web URL from App Platform. Add that URL as FUNCTION_URL on the web service and redeploy the service.
- Add the web service's HTTPS origin to the bucket CORS rule. Continue to the one-image test below. You do not need a separate Functions namespace deployment.
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.
Check your result
- 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.