Export files to DigitalOcean Spaces in .NET (C#)
Use the AWS SDK for .NET to upload a local file to a private DigitalOcean Space, then download it and compare its length and SHA-256 hash. The program below accepts empty files and files up to 64 MiB, and reports success only after verification.
Understand DigitalOcean Spaces S3 compatibility
Spaces implements a subset of the S3 API.
For an existing Space, configure its regional endpoint, the signing region, and Spaces credentials
explicitly. This example uses path-style requests, so the endpoint contains the region but no bucket
name: https://fra1.digitaloceanspaces.com.
You need an existing private Space and a Spaces key that can write and read its objects. For a bucket without a bucket policy, DigitalOcean supports a bucket-scoped key with Read/Write/Delete permissions. Limited keys cannot be combined with bucket policies; see Spaces access management. A DigitalOcean API token is not the Spaces key pair used by this program. This walkthrough assumes a private bucket without a public-read policy.
Install required NuGet packages
The shell commands use Bash on Linux. Install the current .NET 10 SDK, rather than just the runtime. This example was tested with SDK 10.0.402 and runtime 10.0.13. The package versions below pin AWS SDK v4 for a reproducible setup; they are tested versions, not intrinsic minimums.
Start in a writable directory outside an existing .NET project. The block refuses existing
SpacesExport files and ancestor configuration that could change SDK selection, builds, or package
installation. Successful setup enters the new project; failure leaves your shell in its original
directory.
(
command -v dotnet >/dev/null 2>&1 || { printf 'Install the .NET 10 SDK first.\n' >&2; exit 1; }
parent=$PWD
while :; do
for config in "$parent/global.json" "$parent"/Directory.Build.* \
"$parent/Directory.Packages.props" "$parent"/[Nn][Uu][Gg][Ee][Tt].[Cc][Oo][Nn][Ff][Ii][Gg]; do
if [ -e "$config" ]; then
printf 'Use a directory outside existing .NET configuration.\n' >&2
exit 1
fi
done
[ "$parent" = / ] && break
parent=${parent%/*}
[ -n "$parent" ] || parent=/
done
[ ! -e SpacesExport ] || { printf 'SpacesExport already exists.\n' >&2; exit 1; }
dotnet new console --framework net10.0 --name SpacesExport &&
cd SpacesExport &&
dotnet add package AWSSDK.Core --version 4.0.102.10 &&
dotnet add package AWSSDK.S3 --version 4.0.104.3
) && cd SpacesExport
If package installation fails after creating the project, keep that project. Fix the reported NuGet connection or source problem, then retry these commands from the original directory:
dotnet add ./SpacesExport/SpacesExport.csproj package AWSSDK.Core --version 4.0.102.10 &&
dotnet add ./SpacesExport/SpacesExport.csproj package AWSSDK.S3 --version 4.0.104.3
After a successful retry, enter SpacesExport before continuing.
Use AWS SDK for .NET with DigitalOcean Spaces
Provide these environment variables through your secret manager or shell environment. The key and secret must belong to Spaces, not AWS.
| Variable | Value |
|---|---|
SPACES_ENDPOINT | Regional HTTPS endpoint, such as https://fra1.digitaloceanspaces.com |
SPACES_REGION | Region containing the existing Space, such as fra1 |
SPACES_BUCKET | Existing Space name |
SPACES_KEY_ID | Spaces access key ID |
SPACES_APPLICATION_KEY | Spaces secret key |
DigitalOcean’s SDK guide shows
how to select the endpoint. Pass object keys as unencoded strings. The SDK signs the request and
encodes spaces, #, ?, +, percent signs, and Unicode characters.
Replace Program.cs with this complete program. Use regular local files and valid UTF-8 names.
The program rejects the replacement character � in arguments so an invalid native filename cannot
silently resolve to a different file. Keep the local file unchanged until verification finishes,
and use a destination key that another process is not writing.
using System.Security.Cryptography;
using Amazon.Runtime;
using Amazon.S3;
using Amazon.S3.Model;
public static class SpacesExporter
{
private const long MaxBytes = 64L * 1024 * 1024;
public static async Task<int> Main(string[] args)
{
using var deadline = new CancellationTokenSource(TimeSpan.FromMinutes(5));
Console.CancelKeyPress += (_, e) => { e.Cancel = true; deadline.Cancel(); };
try
{
if (args.Length is not (2 or 3) || (args.Length == 3 && args[2] != "--verify-only"))
throw new ArgumentException("Pass a local file, an object key, and optionally --verify-only.");
if (args.Any(value => value.Contains('\uFFFD')))
throw new ArgumentException("Use UTF-8 arguments without the replacement character.");
if (string.IsNullOrWhiteSpace(args[1]))
throw new ArgumentException("An object key is required.");
var endpoint = new Uri(Required("SPACES_ENDPOINT"));
if (endpoint.Scheme != "https" || endpoint.UserInfo.Length != 0 ||
endpoint.AbsolutePath != "/" || endpoint.Query.Length != 0 || endpoint.Fragment.Length != 0)
throw new ArgumentException("Use the HTTPS regional endpoint without a bucket or path.");
var config = new AmazonS3Config
{
ServiceURL = endpoint.AbsoluteUri,
AuthenticationRegion = Required("SPACES_REGION"),
ForcePathStyle = true,
MaxErrorRetry = 2,
RequestChecksumCalculation = RequestChecksumCalculation.WHEN_REQUIRED,
ResponseChecksumValidation = ResponseChecksumValidation.WHEN_REQUIRED
};
using var client = new AmazonS3Client(
Required("SPACES_KEY_ID"), Required("SPACES_APPLICATION_KEY"), config);
var bucket = Required("SPACES_BUCKET");
await using var input = new FileStream(args[0], FileMode.Open, FileAccess.Read, FileShare.Read);
if (input.Length > MaxBytes)
{
Console.Error.WriteLine("File exceeds the 64 MiB limit.");
return 1;
}
var expectedHash = await SHA256.HashDataAsync(input, deadline.Token);
if (args.Length == 2)
await UploadAsync(client, bucket, args[0], args[1], deadline.Token);
using var stored = await client.GetObjectAsync(bucket, args[1], deadline.Token);
if (stored.ContentLength != input.Length)
throw new InvalidDataException();
using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
var buffer = new byte[64 * 1024];
long received = 0;
int count;
while ((count = await stored.ResponseStream.ReadAsync(buffer, deadline.Token)) != 0)
{
received += count;
if (received > input.Length) throw new InvalidDataException();
hash.AppendData(buffer, 0, count);
}
if (received != input.Length || !hash.GetHashAndReset().SequenceEqual(expectedHash))
throw new InvalidDataException();
Console.WriteLine($"Verified {received} bytes.");
return 0;
}
catch (OperationCanceledException) { Console.Error.WriteLine("Transfer canceled or timed out."); }
catch (AmazonS3Exception error) { Console.Error.WriteLine($"Storage returned HTTP {(int)error.StatusCode}."); }
catch (InvalidDataException) { Console.Error.WriteLine("Stored bytes do not match the local file."); }
catch (Exception) { Console.Error.WriteLine("Transfer failed. Check the file, arguments, and storage configuration."); }
return 1;
}
private static string Required(string name) =>
Environment.GetEnvironmentVariable(name) is { Length: > 0 } value
? value : throw new ArgumentException($"Missing {name}.");
public static async Task UploadAsync(IAmazonS3 client, string bucket, string path,
string key, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(key)) throw new ArgumentException("An object key is required.");
await using var input = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
if (input.Length > MaxBytes) throw new IOException("File exceeds the 64 MiB limit.");
await client.PutObjectAsync(new PutObjectRequest
{
BucketName = bucket,
Key = key,
InputStream = input,
AutoCloseStream = false,
ContentType = "application/octet-stream",
UseChunkEncoding = false
}, cancellationToken);
}
}
From the project directory, build and export a file you already have:
dotnet build && dotnet run --no-build -- './report.txt' 'reports/report #1.txt'
Replace both arguments with your local path and destination key. The -- separates .NET CLI
options from the program’s arguments, including filenames beginning with -. A successful run
prints Verified followed by the downloaded byte count and exits
with status zero. The file remains in Spaces. Reusing the same key replaces its current contents;
choose a new key to retain an earlier export.
Add retry logic and error handling
MaxErrorRetry = 2 allows at most two SDK retries per request for retryable failures. The SDK
accepts a cancellation token for asynchronous calls.
This program sets one five-minute deadline for local hashing, upload, retries, and verification together. Even a file below
64 MiB can exceed that deadline on a slow connection. Ctrl+C requests cancellation through the same
token. Neither case prints a verified result, and both exit nonzero.
An upload may have reached Spaces before a response is lost or a later verification fails. The program does not delete the remote object on failure. Inspect that key against the unchanged local file without uploading again:
dotnet build && dotnet run --no-build -- './report.txt' 'reports/report #1.txt' --verify-only
This mode only downloads and compares; it returns nonzero for missing or different content. If it verifies, the current stored object matches your file. Otherwise, resolve the failure before choosing whether to upload again. Verification is an observation of the current object, not a lock against another writer.
Stream bounded files
The upload sends one PUT from an open file stream. Verification reads the response through a 64 KiB buffer and checks the complete length and hash, including for empty files. It downloads the file once, so include that extra transfer when estimating time and bandwidth. The 64 MiB ceiling is this example’s application limit, not the Spaces object-size limit.
The configuration disables chunked upload encoding and requests optional SDK checksums only when an operation requires them. These are the settings used for this Spaces path; the explicit readback supplies its end-to-end content check. The program disposes its streams, response, and client when it succeeds or fails.
This example does not perform multipart uploads. Larger files need a separate multipart workflow, including recovery and cleanup of unfinished uploads; increasing this constant does not provide it.
Manage access keys and permissions securely
Keep credentials in server-side configuration. Do not place the secret in a URL, source file, browser bundle, or captured SDK wire log. This program supplies the key pair explicitly and uses HTTPS; it does not make the Space public or change bucket permissions. Keep diagnostic output limited to the status and the program’s own messages.
Troubleshoot common issues
For Storage returned HTTP 403., check that you supplied the Spaces key pair, that it has both read and write access to this bucket, and that the endpoint and signing region match its location. A successful PUT followed by a denied GET still fails verification.
For File exceeds the 64 MiB limit., use a smaller file or a separate multipart implementation. For Stored bytes do not match the local file., check whether the local file or destination changed during the operation, then use verification-only mode against the intended pair. A connection refusal or exhausted transient HTTP failure returns nonzero; check reachability before retrying. Do not treat a failed invocation as evidence that no object was stored.
