Build an image upload and optimization pipeline
Explore a disposable image-upload starter with private Spaces originals and WebP variants; add authentication, authorization and quotas before public use.
GitHub repository ↗A browser uploads one original directly to private object storage. A small API starts a secured image function, and only optimized variants reach your CDN.
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 this as a local or disposable learning starter with non-sensitive sample images. It has no user authentication or per-user quotas. Anyone who can reach its upload or processing API can create billable storage and processing work. Add an access gate before any publicly reachable deployment, even for a small test. JPEG, PNG and WebP inputs are limited to 10 MB, but that limit is not an abuse control.
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 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 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.git
cd digitalocean-image-upload-pipeline
git checkout 5b8eafad6e2ec0f0ea554af270e349717d1b182f
npm ci
cp .env.example .env.local
npm run dev
Deploy both components from one App Platform app
Before the steps below, put authentication and server-side authorization in front of both /api/uploads and /api/images/:id/process, or protect the entire service behind a private access gate. Add per-user limits and a spending boundary. The companion does not implement these controls. Do not expose the unchanged starter to the public internet. CORS and the Function secret do not authenticate a visitor to the web-service API.
Negative acceptance check: from a signed-out session, send an upload-initiation request and a processing request for another user’s asset. Both must be rejected before a signed URL is minted or a Function runs, with no new object or invocation recorded. Repeat with a valid account exceeding its configured quota. Only continue to the paid deployment steps after those checks pass.
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.
Frequently asked questions
Why not send the file through an App Platform API route?
A presigned Spaces PUT lets the browser upload directly while your API only handles small metadata and authorization decisions. It reduces application traffic and avoids moving image bytes through an API request.
Why are the originals private but variants public?
The original may contain unnecessary metadata or a larger image than the product needs. Keeping it private limits exposure, while generated WebP variants can be cached and served efficiently through the Spaces CDN.
Can I use this for a large media library?
Use the same storage boundary, but add accounts, quotas, content validation, durable job processing, retries, lifecycle cleanup, and observability. A synchronous Function is a good first pipeline, not a complete media platform.
DigitalOcean Functions limits (8 October 2026).