Digitalocean

Spaces Object Storage: S3-Compatible Cloud Storage

A practical guide to DigitalOcean Spaces for Node.js applications covering file uploads, CDN configuration, presigned URLs, lifecycle policies, and migration from local storage.

Spaces Object Storage: S3-Compatible Cloud Storage

DigitalOcean Spaces is object storage with an S3-compatible API. It stores files — images, documents, backups, static assets — in the cloud with a CDN for fast global delivery. Because it uses the S3 API, any AWS SDK code works with Spaces by changing the endpoint URL.

For Node.js applications, Spaces replaces local file storage with scalable cloud storage. No more running out of disk space, no more serving files through your Express server, no more losing uploads when you redeploy.

Prerequisites

  • A DigitalOcean account
  • Node.js installed (v16+)
  • An application that handles file uploads or needs cloud storage

Creating a Space

Via Dashboard

  1. Navigate to Spaces in the DigitalOcean dashboard
  2. Click Create a Space
  3. Choose a region (nyc3, sfo3, ams3, sgp1, fra1)
  4. Name your Space (globally unique, lowercase, no special characters)
  5. Choose file listing: Restrict (recommended for applications)

Via CLI

doctl compute space create my-app-storage --region nyc3

Generate API Keys

Navigate to API > Spaces Keys and create a new key pair. You will get:

  • Access Key — equivalent to AWS Access Key ID
  • Secret Key — equivalent to AWS Secret Access Key

Connecting from Node.js

Using the AWS SDK

npm install @aws-sdk/client-s3
// spaces.js
var { S3Client, PutObjectCommand, GetObjectCommand, DeleteObjectCommand, ListObjectsV2Command } = require("@aws-sdk/client-s3");

var client = new S3Client({
  endpoint: "https://nyc3.digitaloceanspaces.com",
  region: "us-east-1", // Required by SDK but not used by Spaces
  credentials: {
    accessKeyId: process.env.SPACES_KEY,
    secretAccessKey: process.env.SPACES_SECRET
  }
});

var BUCKET = process.env.SPACES_BUCKET || "my-app-storage";

function uploadFile(key, body, contentType) {
  var command = new PutObjectCommand({
    Bucket: BUCKET,
    Key: key,
    Body: body,
    ContentType: contentType,
    ACL: "public-read"
  });

  return client.send(command).then(function() {
    return {
      key: key,
      url: "https://" + BUCKET + ".nyc3.digitaloceanspaces.com/" + key,
      cdnUrl: "https://" + BUCKET + ".nyc3.cdn.digitaloceanspaces.com/" + key
    };
  });
}

function getFile(key) {
  var command = new GetObjectCommand({
    Bucket: BUCKET,
    Key: key
  });

  return client.send(command);
}

function deleteFile(key) {
  var command = new DeleteObjectCommand({
    Bucket: BUCKET,
    Key: key
  });

  return client.send(command);
}

function listFiles(prefix, maxKeys) {
  var command = new ListObjectsV2Command({
    Bucket: BUCKET,
    Prefix: prefix || "",
    MaxKeys: maxKeys || 100
  });

  return client.send(command).then(function(data) {
    return (data.Contents || []).map(function(item) {
      return {
        key: item.Key,
        size: item.Size,
        lastModified: item.LastModified
      };
    });
  });
}

module.exports = {
  uploadFile: uploadFile,
  getFile: getFile,
  deleteFile: deleteFile,
  listFiles: listFiles
};

Using the Legacy AWS SDK (v2)

For projects using the older SDK:

npm install aws-sdk
// spaces-v2.js
var AWS = require("aws-sdk");

var spacesEndpoint = new AWS.Endpoint("nyc3.digitaloceanspaces.com");

var s3 = new AWS.S3({
  endpoint: spacesEndpoint,
  accessKeyId: process.env.SPACES_KEY,
  secretAccessKey: process.env.SPACES_SECRET
});

var BUCKET = process.env.SPACES_BUCKET || "my-app-storage";

function uploadFile(key, body, contentType, callback) {
  var params = {
    Bucket: BUCKET,
    Key: key,
    Body: body,
    ContentType: contentType,
    ACL: "public-read"
  };

  s3.putObject(params, function(err) {
    if (err) return callback(err);
    callback(null, {
      key: key,
      url: "https://" + BUCKET + ".nyc3.digitaloceanspaces.com/" + key
    });
  });
}

module.exports = { uploadFile: uploadFile };

File Upload with Express

Handling Multipart Uploads

npm install multer
// routes/upload.js
var express = require("express");
var multer = require("multer");
var path = require("path");
var crypto = require("crypto");
var spaces = require("../spaces");

var router = express.Router();

// Store files in memory temporarily
var upload = multer({
  storage: multer.memoryStorage(),
  limits: {
    fileSize: 10 * 1024 * 1024 // 10MB limit
  },
  fileFilter: function(req, file, cb) {
    var allowedTypes = ["image/jpeg", "image/png", "image/gif", "image/webp"];
    if (allowedTypes.indexOf(file.mimetype) === -1) {
      return cb(new Error("Only image files are allowed"));
    }
    cb(null, true);
  }
});

router.post("/upload", upload.single("file"), function(req, res) {
  if (!req.file) {
    return res.status(400).json({ error: "No file provided" });
  }

  // Generate unique filename
  var ext = path.extname(req.file.originalname);
  var uniqueName = crypto.randomBytes(16).toString("hex") + ext;
  var key = "uploads/" + uniqueName;

  spaces.uploadFile(key, req.file.buffer, req.file.mimetype)
    .then(function(result) {
      res.json({
        success: true,
        url: result.cdnUrl,
        key: result.key,
        filename: req.file.originalname,
        size: req.file.size
      });
    })
    .catch(function(err) {
      console.error("Upload error:", err);
      res.status(500).json({ error: "Upload failed" });
    });
});

router.delete("/upload/:key", function(req, res) {
  var key = "uploads/" + req.params.key;

  spaces.deleteFile(key)
    .then(function() {
      res.json({ success: true });
    })
    .catch(function(err) {
      console.error("Delete error:", err);
      res.status(500).json({ error: "Delete failed" });
    });
});

module.exports = router;

Organizing Files in Folders

function generateKey(userId, fileType, filename) {
  var ext = path.extname(filename);
  var date = new Date();
  var year = date.getFullYear();
  var month = String(date.getMonth() + 1).padStart(2, "0");

  // Structure: users/123/avatars/2026/01/abc123.jpg
  return [
    "users",
    userId,
    fileType,
    year,
    month,
    crypto.randomBytes(8).toString("hex") + ext
  ].join("/");
}

// Usage
var key = generateKey(user.id, "avatars", "profile.jpg");
// Result: users/42/avatars/2026/01/a1b2c3d4e5f6g7h8.jpg

Presigned URLs

Presigned URLs allow clients to upload directly to Spaces without going through your server. This reduces server load and speeds up uploads.

Generate Upload URL

var { PutObjectCommand } = require("@aws-sdk/client-s3");
var { getSignedUrl } = require("@aws-sdk/s3-request-presigner");

function createUploadUrl(key, contentType, expiresIn) {
  var command = new PutObjectCommand({
    Bucket: BUCKET,
    Key: key,
    ContentType: contentType
  });

  return getSignedUrl(client, command, { expiresIn: expiresIn || 3600 });
}

// Express route
router.post("/upload-url", function(req, res) {
  var filename = req.body.filename;
  var contentType = req.body.contentType;

  if (!filename || !contentType) {
    return res.status(400).json({ error: "filename and contentType required" });
  }

  var ext = path.extname(filename);
  var key = "uploads/" + crypto.randomBytes(16).toString("hex") + ext;

  createUploadUrl(key, contentType)
    .then(function(url) {
      res.json({
        uploadUrl: url,
        key: key,
        publicUrl: "https://" + BUCKET + ".nyc3.cdn.digitaloceanspaces.com/" + key
      });
    })
    .catch(function(err) {
      res.status(500).json({ error: "Failed to generate upload URL" });
    });
});

Client-Side Direct Upload

// Browser JavaScript
function uploadFile(file) {
  // Step 1: Get presigned URL from your server
  return fetch("/api/upload-url", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      filename: file.name,
      contentType: file.type
    })
  })
  .then(function(res) { return res.json(); })
  .then(function(data) {
    // Step 2: Upload directly to Spaces
    return fetch(data.uploadUrl, {
      method: "PUT",
      headers: { "Content-Type": file.type },
      body: file
    }).then(function() {
      return data.publicUrl;
    });
  });
}

CDN Configuration

Spaces includes a built-in CDN. Enable it in the Space settings.

CDN URLs

Origin:  https://my-bucket.nyc3.digitaloceanspaces.com/image.jpg
CDN:     https://my-bucket.nyc3.cdn.digitaloceanspaces.com/image.jpg

Custom CDN Domain

  1. Enable CDN in Space settings
  2. Add a custom subdomain (e.g., cdn.myapp.example.com)
  3. Create a CNAME record pointing to the CDN endpoint
  4. DigitalOcean provisions an SSL certificate automatically
// Use CDN URLs in your application
var CDN_BASE = process.env.CDN_URL || "https://my-bucket.nyc3.cdn.digitaloceanspaces.com";

function getPublicUrl(key) {
  return CDN_BASE + "/" + key;
}

Cache Headers

Set cache headers when uploading for CDN optimization:

function uploadWithCaching(key, body, contentType, maxAge) {
  var command = new PutObjectCommand({
    Bucket: BUCKET,
    Key: key,
    Body: body,
    ContentType: contentType,
    ACL: "public-read",
    CacheControl: "public, max-age=" + (maxAge || 86400) // Default 1 day
  });

  return client.send(command);
}

// Static assets — cache for 1 year
uploadWithCaching("static/app.css", cssContent, "text/css", 31536000);

// User uploads — cache for 1 day
uploadWithCaching("uploads/photo.jpg", imageBuffer, "image/jpeg", 86400);

Lifecycle Policies

Automatically delete or transition objects based on age.

CORS Configuration

If clients upload directly to Spaces, configure CORS:

var { PutBucketCorsCommand } = require("@aws-sdk/client-s3");

function configureCors() {
  var command = new PutBucketCorsCommand({
    Bucket: BUCKET,
    CORSConfiguration: {
      CORSRules: [
        {
          AllowedOrigins: ["https://myapp.example.com"],
          AllowedMethods: ["GET", "PUT", "POST"],
          AllowedHeaders: ["*"],
          MaxAgeSeconds: 3600
        }
      ]
    }
  });

  return client.send(command);
}

Cleanup Old Files

// cleanup.js — run on a schedule
var spaces = require("./spaces");

function deleteOldTempFiles(maxAgeDays) {
  var cutoff = new Date();
  cutoff.setDate(cutoff.getDate() - maxAgeDays);

  return spaces.listFiles("temp/").then(function(files) {
    var oldFiles = files.filter(function(file) {
      return file.lastModified < cutoff;
    });

    var deletes = oldFiles.map(function(file) {
      return spaces.deleteFile(file.key);
    });

    return Promise.all(deletes).then(function() {
      console.log("Deleted " + oldFiles.length + " old temp files");
    });
  });
}

// Run daily
deleteOldTempFiles(7);

Migration from Local Storage

Migrating Existing Files

// migrate-to-spaces.js
var fs = require("fs");
var path = require("path");
var spaces = require("./spaces");
var mime = require("mime-types");

function migrateDirectory(localDir, spacesPrefix) {
  var files = fs.readdirSync(localDir);
  var uploaded = 0;
  var failed = 0;

  function uploadNext(index) {
    if (index >= files.length) {
      console.log("Migration complete: " + uploaded + " uploaded, " + failed + " failed");
      return Promise.resolve();
    }

    var filename = files[index];
    var localPath = path.join(localDir, filename);
    var stat = fs.statSync(localPath);

    if (stat.isDirectory()) {
      return migrateDirectory(localPath, spacesPrefix + filename + "/")
        .then(function() { return uploadNext(index + 1); });
    }

    var body = fs.readFileSync(localPath);
    var contentType = mime.lookup(filename) || "application/octet-stream";
    var key = spacesPrefix + filename;

    return spaces.uploadFile(key, body, contentType)
      .then(function() {
        uploaded++;
        console.log("Uploaded: " + key);
        return uploadNext(index + 1);
      })
      .catch(function(err) {
        failed++;
        console.error("Failed: " + key + " — " + err.message);
        return uploadNext(index + 1);
      });
  }

  return uploadNext(0);
}

// Migrate local uploads directory to Spaces
migrateDirectory("./uploads", "uploads/");

Updating Application Code

// Before: local file storage
app.use("/uploads", express.static("uploads"));

// After: Spaces URLs in database
// No need to serve files through Express
// Files are served directly from CDN
function getFileUrl(fileRecord) {
  if (fileRecord.spacesKey) {
    return process.env.CDN_URL + "/" + fileRecord.spacesKey;
  }
  // Fallback for unmigrated files
  return "/uploads/" + fileRecord.filename;
}

Complete Upload Service

// uploadService.js
var crypto = require("crypto");
var path = require("path");
var spaces = require("./spaces");

var ALLOWED_IMAGE_TYPES = ["image/jpeg", "image/png", "image/gif", "image/webp"];
var MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB

function UploadService(options) {
  this.cdnUrl = options.cdnUrl;
  this.maxFileSize = options.maxFileSize || MAX_FILE_SIZE;
}

UploadService.prototype.validateFile = function(file) {
  if (!file) return { valid: false, error: "No file provided" };
  if (file.size > this.maxFileSize) return { valid: false, error: "File too large" };
  if (ALLOWED_IMAGE_TYPES.indexOf(file.mimetype) === -1) {
    return { valid: false, error: "Invalid file type" };
  }
  return { valid: true };
};

UploadService.prototype.upload = function(file, folder) {
  var validation = this.validateFile(file);
  if (!validation.valid) {
    return Promise.reject(new Error(validation.error));
  }

  var ext = path.extname(file.originalname).toLowerCase();
  var uniqueName = crypto.randomBytes(16).toString("hex") + ext;
  var key = (folder || "uploads") + "/" + uniqueName;
  var cdnUrl = this.cdnUrl;

  return spaces.uploadFile(key, file.buffer, file.mimetype)
    .then(function() {
      return {
        key: key,
        url: cdnUrl + "/" + key,
        filename: file.originalname,
        size: file.size,
        contentType: file.mimetype
      };
    });
};

UploadService.prototype.delete = function(key) {
  return spaces.deleteFile(key);
};

module.exports = UploadService;

Common Issues and Troubleshooting

"Access Denied" when uploading

The API keys do not have permission or the bucket name is wrong:

Fix: Verify the Spaces API key and secret are correct. Check the bucket name matches exactly (case-sensitive). Ensure the key was created for Spaces, not the main API.

CORS errors on direct browser uploads

CORS is not configured on the Space:

Fix: Configure CORS rules on the Space via the API or dashboard. Allow the specific origins your frontend uses. Include PUT and POST in allowed methods.

Files upload but are not publicly accessible

The ACL is not set to public-read:

Fix: Add ACL: "public-read" to the PutObjectCommand parameters. Or configure the Space's default file access to public.

CDN serves stale content after update

The CDN caches files based on the Cache-Control header:

Fix: Use unique filenames (hash-based) for static assets so new versions get new URLs. Set appropriate max-age values. For files that update in place, use shorter cache times or purge the CDN cache.

Best Practices

  • Use the CDN for all public files. CDN URLs serve content from edge locations closer to users. Always use CDN URLs in your HTML and API responses.
  • Generate unique filenames. Never use the original filename as the key. Users upload files with identical names. Use random hashes to prevent collisions and overwriting.
  • Set appropriate ACLs. Use public-read for images and static assets that users see directly. Keep sensitive files private and use presigned URLs for temporary access.
  • Use presigned URLs for large uploads. Uploading through your Express server limits you to your server's bandwidth and memory. Presigned URLs let clients upload directly to Spaces.
  • Set Cache-Control headers on upload. Static assets (CSS, JS, fonts) should cache for a year. User uploads should cache for a day. Temporary files should not cache at all.
  • Organize files with path prefixes. Use a clear folder structure: users/123/avatars/, articles/456/images/, backups/2026/01/. This makes listing and cleaning up files easier.
  • Clean up temporary files. Files in temporary folders should be deleted on a schedule. Use a cron job or scheduled task to remove old temp files weekly.
  • Store file metadata in your database. Record the Spaces key, original filename, size, content type, and upload date. This allows your application to manage files without listing the Space.

References

Powered by Contentful