Upload providers
Configure file storage with local filesystem, Amazon S3, Cloudflare R2, or Google Cloud Storage.
Upload providers handle storage for both challenge file attachments and team avatars. Both share the same provider, so anything you configure here applies to both.
Warning (v2 needs delete permissions)
Unlike rCTF v1, the v2 upload provider needs permission to delete objects, not just upload them. Avatar replacement and admin-side file deletion both depend on it. If you reuse a v1 GCS IAM, add storage.objects.delete.
Configuration
uploadProvider: name: uploads/s3 options: bucketName: my-ctf-uploads awsKeyId: AKIAIOSFODNN7EXAMPLE awsKeySecret: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY awsRegion: us-east-1You can keep storage credentials out of config files by using environment variables. Set RCTF_UPLOAD_PROVIDER to a provider such as uploads/s3, then provide the settings through the variables listed below. Environment values override the config file.
File handling
- Files are stored by their SHA256 hash, so uploading the same file content twice doesn’t create a duplicate.
- All uploaded files are served with immutable cache headers (
max-age=31536000). - Team avatars are resized to 256x256 pixels and converted to WebP format before upload. The maximum avatar size comes from the
maxAvatarSizeconfig option (default 1 MB).
Providers
Stores files on the local filesystem. This is the default provider.
uploadProvider: name: uploads/local options: uploadDirectory: /path/to/uploads # Optional| Option | Default | Description |
|---|---|---|
uploadDirectory |
{cwd}/uploads/ |
Directory for file storage |
Files are served by the API server at /uploads/*. Path traversal protection is built in.
Tip
The local provider works well for development and small events. S3, R2, or GCS is a better fit when many participants will download large challenge files, since those downloads no longer pass through the rCTF server.
Stores files in an Amazon S3 bucket.
uploadProvider: name: uploads/s3 options: bucketName: my-ctf-uploads awsKeyId: AKIAIOSFODNN7EXAMPLE awsKeySecret: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY awsRegion: us-east-1| Option | Environment Variable | Description |
|---|---|---|
bucketName |
RCTF_S3_BUCKET |
S3 bucket name |
awsKeyId |
RCTF_S3_KEY_ID |
AWS access key ID |
awsKeySecret |
RCTF_S3_KEY_SECRET |
AWS secret access key |
awsRegion |
RCTF_S3_REGION |
AWS region |
Files are stored with public-read ACL and attachment content disposition. The bucket has to allow public reads.
Stores files in a Cloudflare R2 bucket.
uploadProvider: name: uploads/r2 options: bucketName: my-ctf-uploads cfAccountId: 0123456789abcdef0123456789abcdef cfKeyId: 0123456789abcdef0123456789abcdef cfKeySecret: 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef publicBaseUrl: https://cdn.example.com jurisdiction: default # optional: default, eu, or fedramp| Option | Environment Variable | Description |
|---|---|---|
bucketName |
RCTF_R2_BUCKET |
R2 bucket name |
cfAccountId |
RCTF_R2_ACCOUNT_ID |
Cloudflare account ID |
cfKeyId |
RCTF_R2_KEY_ID |
R2 access key ID |
cfKeySecret |
RCTF_R2_KEY_SECRET |
R2 secret access key |
publicBaseUrl |
RCTF_R2_PUBLIC_BASE_URL |
Public bucket URL or custom domain |
jurisdiction |
RCTF_R2_JURISDICTION |
R2 jurisdiction: default, eu, or fedramp |
The R2 API token needs permission to read, write, and delete objects. Configure the bucket for public access and set publicBaseUrl to its custom domain. Set jurisdiction for buckets created in the EU or FedRAMP jurisdiction; the location hint used when creating a default-jurisdiction bucket does not change this setting.
Stores files in a Google Cloud Storage bucket.
uploadProvider: name: uploads/gcs options: projectId: my-gcp-project bucketName: my-ctf-uploads credentials: type: service_account project_id: my-gcp-project private_key_id: ... private_key: ... client_email: ...| Option | Environment Variable | Description |
|---|---|---|
projectId |
RCTF_GCS_PROJECT_ID |
GCP project ID |
bucketName |
RCTF_GCS_BUCKET |
GCS bucket name |
credentials |
RCTF_GCS_CREDENTIALS |
GCP credentials object (env var accepts JSON string) |
Files are stored with public visibility. The bucket has to be configured to allow public access.
Terraform templates
Terraform modules under deploy/terraform/storage/ can create an S3, R2, or GCS bucket, its CORS rules, and credentials limited to the permissions rCTF needs. Each module exposes the generated credentials as sensitive Terraform outputs for use in rctf.d/.
cd deploy/terraform/storage/mainterraform initterraform apply -var="region=eu-west-3" -var="bucket_name=my-ctf-uploads"Read the credentials for the dedicated rctf-bucket IAM user from the sensitive outputs (rctf_iam_user_arn and bucket are printed to stdout on apply):
terraform output -raw access_key_idterraform output -raw secret_access_keyDrop the credentials into your config:
uploadProvider: name: uploads/s3 options: bucketName: my-ctf-uploads awsKeyId: <access_key_id output> awsKeySecret: <secret_access_key output> awsRegion: eu-west-3The module sets up CORS (GET, HEAD from any origin by default, which you can override with -var="cors_allowed_origins=[\"https://ctf.example.com\"]") and grants the IAM user s3:GetObject, s3:PutObject, s3:DeleteObject, plus the matching ACL actions and s3:ListBucket.
cd deploy/terraform/storage/r2terraform initterraform apply -var="cloudflare_api_token=[your bootstrap token]" -var="location=weur" -var="bucket_name=my-ctf-uploads" -var="zone_id=your-domain-zone-id"The location variable is an R2 location hint; see the available hints for the possible values.
The public R2 hostname defaults to cdn.<your-zone-domain>. Override the optional subdomain variable if you want a different hostname, for example with -var="subdomain=files".
Terraform requires a Cloudflare account API token with Workers R2 Storage: Edit, Account API Tokens: Edit, and Zone: Read. Create one with this preconfigured token template and pass it as the sensitive cloudflare_api_token variable. The module derives the account ID and base domain from the supplied zone ID, then creates a separate account API token with object access scoped to the new bucket. Only use the bootstrap token for Terraform; use the generated bucket-scoped credentials in rCTF.
Buckets are created in the standard jurisdiction by default. Pass -var="jurisdiction=eu" or -var="jurisdiction=fedramp" to create the bucket, its custom domain, and the bucket-scoped token inside a jurisdiction, and set the matching jurisdiction option in the rCTF config.
Read the generated values from the Terraform outputs. The access key and secret are sensitive, so Terraform does not print them after apply:
terraform output -raw access_key_idterraform output -raw secret_access_keyDrop the outputs into your config:
uploadProvider: name: uploads/r2 options: bucketName: <bucket output> cfAccountId: <account_id output> cfKeyId: <access_key_id output> cfKeySecret: <secret_access_key output> publicBaseUrl: <public_base_url output>The module creates the R2 bucket, connects the derived hostname as its public custom domain, and creates an account API token with object read, write, and delete access scoped to that bucket. Cloudflare creates the custom domain’s DNS record when it connects the bucket, so the hostname must not already have a conflicting DNS record. It also allows CORS GET and HEAD requests from any origin by default. Restrict the allowed origins with -var="cors_allowed_origins=[\"https://ctf.example.com\"]".
cd deploy/terraform/storage/gcsterraform initterraform apply -var="project_id=my-gcp-project" -var="region=europe-west1" -var="bucket_name=my-ctf-uploads"Read the key for the dedicated rctf-bucket service account from the sensitive output (rctf_sa_email and bucket are printed to stdout on apply):
terraform output -raw service_account_keyDrop the credentials into your config (paste the key JSON under credentials):
uploadProvider: name: uploads/gcs options: projectId: my-gcp-project bucketName: my-ctf-uploads credentials: type: service_account project_id: my-gcp-project # ...rest of the service_account_key outputThe module sets up CORS (GET, HEAD from any origin by default, which you can override with -var="cors_allowed_origins=[\"https://ctf.example.com\"]") and grants the service account a custom role with storage.objects.create, storage.objects.get, storage.objects.list, and storage.objects.delete.