Guide · Cloud Storage

MCP Tools for DigitalOcean Spaces — S3-compatible API, CDN edge caching, CORS, presigned URLs

Three DigitalOcean Spaces behaviours catch developers building MCP storage tools: Spaces has two distinct endpoints — the origin endpoint and the CDN endpoint — and writes must go to the origin while public reads should go to the CDN — the origin endpoint (<bucket>.<region>.digitaloceanspaces.com) is where you write objects via the S3 API, and the CDN endpoint (<bucket>.<region>.cdn.digitaloceanspaces.com) is the edge-cached read URL; writing to the CDN endpoint fails; reading from the origin endpoint bypasses CDN caching and incurs higher latency and bandwidth costs; presigned URLs signed against the origin endpoint are not cacheable by the CDN — the CDN endpoint strips query parameters, so a presigned URL that routes through the CDN will have its signature stripped and return 403; presigned URLs must use the origin endpoint URL directly; and Spaces access keys are account-level, not bucket-level — a Spaces access key grants access to all Spaces buckets in the account, unlike AWS S3 where IAM policies can scope credentials to specific buckets; there is no bucket-scoped credential in Spaces, so a compromised Spaces key exposes all buckets in the account.

TL;DR

S3 client: new S3Client({ region: 'nyc3', endpoint: 'https://nyc3.digitaloceanspaces.com', credentials: { accessKeyId: SPACES_KEY, secretAccessKey: SPACES_SECRET } }). Public CDN URL: https://bucket.nyc3.cdn.digitaloceanspaces.com/key. Presigned URL: use origin endpoint, not CDN. Health: HeadBucketCommand to origin endpoint — 200 = ok, 403 = invalid key. No forcePathStyle needed.

Client setup — origin endpoint and Spaces access keys

DigitalOcean Spaces uses the AWS S3 SDK with a regional endpoint override. The endpoint format is https://<region>.digitaloceanspaces.com — do not include the bucket name in the endpoint (that's the origin URL, not the API endpoint). Spaces access keys are created under DigitalOcean Dashboard → API → Spaces Keys — they are distinct from DigitalOcean API tokens (which control Droplets, Databases, etc., not Spaces). A Spaces key grants access to all Spaces in the account; there is no bucket-scoped restriction.

import { S3Client, PutObjectCommand, GetObjectCommand,
         HeadBucketCommand, ListObjectsV2Command,
         DeleteObjectCommand, PutBucketCorsCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';

// Region options: nyc3, sfo3, ams3, sgp1, fra1, blr1, syd1
const SPACES_REGION = process.env.SPACES_REGION ?? 'nyc3';
const SPACES_BUCKET = process.env.SPACES_BUCKET!;

const spaces = new S3Client({
  region: SPACES_REGION,
  endpoint: `https://${SPACES_REGION}.digitaloceanspaces.com`,
  credentials: {
    accessKeyId: process.env.SPACES_ACCESS_KEY!,
    secretAccessKey: process.env.SPACES_SECRET_KEY!,
  },
  // forcePathStyle not needed — Spaces uses virtual-hosted style
});

// Public origin URL (no CDN caching): https://{bucket}.{region}.digitaloceanspaces.com/{key}
function getOriginUrl(key: string): string {
  return `https://${SPACES_BUCKET}.${SPACES_REGION}.digitaloceanspaces.com/${key}`;
}

// Public CDN URL (edge-cached): https://{bucket}.{region}.cdn.digitaloceanspaces.com/{key}
// Only works for objects with public-read ACL or in a public bucket
function getCdnUrl(key: string): string {
  return `https://${SPACES_BUCKET}.${SPACES_REGION}.cdn.digitaloceanspaces.com/${key}`;
}

// Verify Spaces access at startup
async function verifySpacesAccess() {
  await spaces.send(new HeadBucketCommand({ Bucket: SPACES_BUCKET }));
  console.log(`Spaces bucket ${SPACES_BUCKET} in ${SPACES_REGION} accessible`);
}

Spaces charges $5/month for 250 GB storage + 1 TB outbound bandwidth, then $0.02/GB storage and $0.01/GB bandwidth overage. The CDN is included in the flat fee — all CDN egress counts against the included 1 TB. Writing objects to Spaces does not count as bandwidth. Inbound bandwidth (uploads) is always free. For MCP servers that frequently upload large files and serve them to end users, Spaces is often cheaper than S3 for the same use case at indie-developer scale.

Uploading objects and setting access control

Spaces supports object-level ACLs via the x-amz-acl header — unlike Cloudflare R2, which has no per-object ACLs. Set ACL: 'public-read' on objects that should be publicly accessible via the CDN URL. Objects without an ACL default to private (accessible only via presigned URLs or credentials). Spaces does not support the full S3 ACL grammar — only private and public-read are supported.

// Upload a private file (default — accessible via presigned URL only)
async function uploadPrivate(key: string, data: Buffer, contentType: string) {
  await spaces.send(new PutObjectCommand({
    Bucket: SPACES_BUCKET,
    Key: key,
    Body: data,
    ContentType: contentType,
    ACL: 'private',  // default; accessible via presigned URL
  }));
  return { originUrl: getOriginUrl(key) };
}

// Upload a public file (accessible at CDN URL without presigning)
async function uploadPublic(key: string, data: Buffer, contentType: string) {
  await spaces.send(new PutObjectCommand({
    Bucket: SPACES_BUCKET,
    Key: key,
    Body: data,
    ContentType: contentType,
    ACL: 'public-read',  // publicly accessible via CDN
    // Set Cache-Control for CDN behaviour
    CacheControl: 'public, max-age=86400',  // 24-hour CDN cache
  }));
  return { cdnUrl: getCdnUrl(key) };
}

// Upload from a stream (for large files received from external sources)
import { Upload } from '@aws-sdk/lib-storage';
import { Readable } from 'stream';

async function uploadStream(key: string, stream: Readable, contentType: string, isPublic = false) {
  const upload = new Upload({
    client: spaces,
    params: {
      Bucket: SPACES_BUCKET,
      Key: key,
      Body: stream,
      ContentType: contentType,
      ACL: isPublic ? 'public-read' : 'private',
    },
    partSize: 10 * 1024 * 1024,
    queueSize: 3,
  });
  await upload.done();
}

Presigned URLs — origin endpoint only

Presigned URLs for Spaces must use the origin endpoint and cannot be served via the CDN. The CDN endpoint strips query string parameters (including the signature) before forwarding the request to the origin — resulting in a 403 because the signature is missing. Always use the origin-based presigned URL for private file access, and use the CDN URL only for public objects. The presigned URL pattern works identically to S3 since the underlying API is the same.

// Presigned download URL — uses origin endpoint (not CDN)
async function getSpacesDownloadUrl(key: string, expiresInSeconds = 3600): Promise {
  const url = await getSignedUrl(
    spaces,
    new GetObjectCommand({ Bucket: SPACES_BUCKET, Key: key }),
    { expiresIn: expiresInSeconds }
  );
  // URL will be: https://{bucket}.{region}.digitaloceanspaces.com/{key}?X-Amz-Signature=...
  // Do NOT rewrite to CDN URL — signature will be stripped and request will fail
  return url;
}

// Presigned upload URL — for browser or mobile client direct upload
async function getSpacesUploadUrl(
  key: string,
  contentType: string,
  expiresInSeconds = 600
): Promise {
  return getSignedUrl(
    spaces,
    new PutObjectCommand({
      Bucket: SPACES_BUCKET,
      Key: key,
      ContentType: contentType,
      // Note: ACL cannot be specified in presigned PUT — the object will be private
      // To make uploaded objects public, call PutObjectAclCommand after upload,
      // or change bucket-level default ACL
    }),
    { expiresIn: expiresInSeconds }
  );
}

// Download to Buffer
async function downloadFromSpaces(key: string): Promise {
  const response = await spaces.send(new GetObjectCommand({ Bucket: SPACES_BUCKET, Key: key }));
  const stream = response.Body as NodeJS.ReadableStream;
  const chunks: Buffer[] = [];
  for await (const chunk of stream) {
    chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
  }
  return Buffer.concat(chunks);
}

CORS configuration for browser uploads

For MCP servers that generate presigned upload URLs for browser clients, configure CORS on the Spaces bucket. Without CORS, browsers reject the cross-origin PUT request from the frontend origin to the Spaces origin, even though the presigned URL is valid. CORS must be set on the bucket via the S3 API or the DigitalOcean dashboard — it cannot be inferred or auto-configured.

// Configure CORS to allow browser uploads from your frontend domain
async function configureBucketCors(allowedOrigins: string[]) {
  await spaces.send(new PutBucketCorsCommand({
    Bucket: SPACES_BUCKET,
    CORSConfiguration: {
      CORSRules: [
        {
          AllowedOrigins: allowedOrigins,  // e.g., ['https://app.yourdomain.com']
          AllowedMethods: ['GET', 'PUT', 'HEAD'],
          AllowedHeaders: ['*'],
          ExposeHeaders: ['ETag'],
          MaxAgeSeconds: 3600,
        },
        // Separate rule for public CDN reads (if bucket has public objects)
        {
          AllowedOrigins: ['*'],
          AllowedMethods: ['GET', 'HEAD'],
          AllowedHeaders: ['Authorization', 'Content-Type'],
          MaxAgeSeconds: 86400,
        },
      ],
    },
  }));
}

// Run once at deployment:
// await configureBucketCors(['https://your-app.com', 'http://localhost:3000']);

Health probe — Spaces bucket and CDN availability

Use HeadBucketCommand against the origin endpoint as the primary health probe. CDN availability is separate — the CDN can be degraded while the origin is healthy, or the CDN can serve cached objects while the origin is temporarily unavailable. If your MCP server serves public objects via the CDN URL, add a secondary probe that fetches a known public object from the CDN endpoint.

async function checkSpacesHealth(): Promise {
  const results: Record = {};

  // Origin health — S3 API connectivity and credential validity
  try {
    const start = Date.now();
    await spaces.send(new HeadBucketCommand({ Bucket: SPACES_BUCKET }));
    results.originAlive = true;
    results.originLatencyMs = Date.now() - start;
  } catch (err: any) {
    results.originAlive = false;
    results.originError = err.name;
    results.originStatusCode = err.$metadata?.httpStatusCode;
  }

  // CDN health — only check if you serve public objects via CDN URL
  const cdnProbeKey = process.env.SPACES_CDN_PROBE_KEY;
  if (cdnProbeKey && results.originAlive) {
    try {
      const cdnUrl = getCdnUrl(cdnProbeKey);
      const start = Date.now();
      const response = await fetch(cdnUrl, { method: 'HEAD' });
      results.cdnAlive = response.ok;
      results.cdnLatencyMs = Date.now() - start;
      results.cdnCacheStatus = response.headers.get('cf-cache-status') ?? 'unknown';
    } catch (err: any) {
      results.cdnAlive = false;
      results.cdnError = err.message;
    }
  }

  return results;
}

		

AliveMCP can monitor both the origin and CDN layers of a Spaces-backed MCP endpoint. CDN probe latency should be consistently under 50 ms from most regions when content is cached (HIT status). Cache MISS latency is higher (150–400 ms) and indicates the CDN is fetching from origin — normal for first requests but anomalous if it persists, which may indicate the CDN cache was purged or the TTL is set too low.

Related guides