Skip to main content

File Storage & Attachments

TestPlanIt supports file attachments across test cases, test runs, sessions, and other entities. The system supports AWS S3 and S3-compatible storage backends (such as the bundled RustFS) for secure, scalable file management.

Overview​

The file storage system provides:

  • Secure file uploads with signed URL generation
  • Multiple storage backends (AWS S3, RustFS, and other S3-compatible services)
  • File type validation and size limits
  • Attachment management across all entities
  • Access control based on user permissions

Supported File Types​

TestPlanIt accepts a wide variety of file types:

  • Images: PNG, JPG, JPEG, GIF, WebP, SVG
  • Documents: PDF, DOC, DOCX, TXT, RTF
  • Spreadsheets: XLS, XLSX, CSV
  • Presentations: PPT, PPTX
  • Archives: ZIP, RAR, 7Z
  • Code: JS, TS, JSON, XML, HTML, CSS
  • Other: Log files, configuration files, and more

Storage Configuration​

AWS S3 Setup​

Configure AWS S3 in your .env file:

# AWS S3 Configuration
AWS_ACCESS_KEY_ID=your_access_key
AWS_SECRET_ACCESS_KEY=your_secret_key
AWS_REGION=us-east-1
AWS_S3_BUCKET_NAME=your-bucket-name

# Optional: Custom S3 endpoint (for S3-compatible services)
# AWS_S3_ENDPOINT=https://your-s3-endpoint.com
Avoid dots in the bucket name

A name like com.example.testplanit cannot be addressed as com.example.testplanit.s3.<region>.amazonaws.com, because a TLS wildcard matches only one label — the certificate check fails outright.

The AWS SDK detects this and falls back to path-style URLs (https://s3.<region>.amazonaws.com/<bucket>/<key>), so TestPlanIt works with such a bucket. But anything that builds an S3 URL by hand, or any tool assuming virtual-hosted addressing, will break against it. Prefer a name with no dots.

Running without static keys​

When TestPlanIt runs on AWS, you can skip the two credential lines entirely:

# No AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY at all
AWS_REGION=us-east-1
AWS_BUCKET_NAME=your-bucket-name

With both omitted, the AWS SDK uses its default provider chain and picks up whichever role the workload already has — an EC2 instance role, EKS IRSA, an ECS task role, SSO, or the shared config file. Nothing long-lived is stored in .env, and credentials rotate on their own.

Grant the permissions below to that role instead of to an IAM user. Omit both variables or set both: supplying only one is treated as supplying neither, and the container will fail to reach storage.

This applies to AWS only. RustFS and other S3-compatible services authenticate with static keys, so those deployments always set both.

Required AWS Permissions​

Your AWS user — or the role, if you are using the provider chain above — needs the following S3 permissions:

{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"s3:GetObject",
"s3:PutObject",
"s3:DeleteObject",
"s3:GetObjectVersion",
"s3:PutObjectAcl"
],
"Resource": "arn:aws:s3:::your-bucket-name/*"
},
{
"Effect": "Allow",
"Action": [
"s3:ListBucket",
"s3:GetBucketLocation"
],
"Resource": "arn:aws:s3:::your-bucket-name"
}
]
}

Bundled Storage (RustFS)​

The Docker Compose files and the Helm chart include RustFS, an open-source (Apache-2.0) S3-compatible server, as the bundled file storage. For compatibility with existing configurations, the service is still named minio and is enabled with the with-minio profile of docker-compose.prod.yml (or the prod profile of docker-compose.yml):

# Bundled storage (RustFS)
AWS_ACCESS_KEY_ID=minioadmin
AWS_SECRET_ACCESS_KEY=minioadmin123
AWS_REGION=us-east-1
AWS_BUCKET_NAME=testplanit
AWS_ENDPOINT_URL=http://minio:9000

The minio-init service creates the bucket on startup, allows anonymous read-only access to its files, and sets the CORS rules browsers need for presigned uploads. Files are stored in ./docker-data/rustfs.

The RustFS console is available at http://localhost:9001 (set with DOCKER_MINIO_CONSOLE_PORT). Sign in with the access key and secret above.

Upgrading from MinIO

Earlier releases bundled MinIO, which no longer publishes Docker images. Existing Docker Compose installs must migrate their files to RustFS, and TestPlanIt will not start until they do. Helm installs need a volume snapshot before upgrading. See Migrating from MinIO to RustFS.

Other S3-Compatible Services​

Any S3-compatible service works, including a MinIO server you run yourself. Set AWS_ENDPOINT_URL to the service's S3 API URL, provide its access key and secret, and create the bucket (testplanit or your configured AWS_BUCKET_NAME). See Storage Modes to choose between direct and proxy access.

File Upload Process​

User Interface​

Users can attach files in several ways:

  1. Drag and Drop: Drag files directly onto attachment areas
  2. File Browser: Click "Choose Files" to open file picker
  3. Rich Text Editor: Paste images directly into rich text fields
  4. Bulk Upload: Select multiple files at once

Anywhere you can upload a file as an attachment — test cases, test runs, results, exploratory sessions — you can also attach an external link instead of a file. Click Add Link next to the file picker, paste the URL, optionally give it a display name and a short description, and click Add. The link appears in the attachment list alongside any uploaded files.

External-link attachments are stored as regular Attachments rows with mimeType: "text/uri-list" — no file is uploaded to storage, the URL itself is the payload. The attachment list renders them with a link icon (instead of a file thumbnail) and opens them in a new tab when clicked. Display, edit, and delete behave the same as for file attachments.

Use this when the evidence lives in another system — a Jira ticket, a CI build, a Looker dashboard, a Confluence runbook, a recorded session — rather than as a downloadable file.

Storage Modes​

TestPlanIt supports two upload modes depending on your deployment:

Direct Mode (Default)​

Used when storage is publicly accessible from the browser:

  1. Frontend requests a presigned URL from the API
  2. Browser uploads directly to storage using the presigned URL
  3. No file data passes through the Next.js server

This is the most efficient mode as files upload directly to storage.

Direct mode needs storage to be anonymously readable, not just reachable

After uploading, the app keeps the presigned URL with its query signature stripped and stores that bare URL as the file's location. Every later read of that file is therefore an unauthenticated request straight to your bucket.

So direct mode works only if objects can be fetched with no credentials. On AWS S3 that means a bucket open to the world — the opposite of the Block Public Access settings recommended above. A private S3 bucket must use proxy mode.

Get this wrong and uploads appear to succeed (the PUT really does store the object) while the file is unreadable afterwards, returning:

<Error><Code>AccessDenied</Code><Message>Access Denied</Message></Error>

Proxy Mode​

Used when storage is not publicly accessible from the browser. That covers two common setups:

  • The bundled storage running inside a Docker network, reachable only as http://minio:9000
  • A private S3 bucket — any bucket with Block Public Access enabled, which is the recommended configuration
  1. Browser sends file to a Next.js server action
  2. Server action uploads the file to storage internally
  3. Server returns a proxy URL (/api/storage/...) for accessing the file

Enable proxy mode by setting IS_HOSTED=true in your environment:

IS_HOSTED=true
When to use Proxy Mode

Enable proxy mode if either is true:

  • Your storage is reachable only inside the Docker network (http://minio:9000). Without proxy mode the app hands the browser presigned URLs pointing at an internal hostname, causing mixed content errors and upload failures.
  • Your bucket blocks public access. Uploads will succeed and then every read fails with AccessDenied, because the stored URL is unsigned — see the warning under Direct Mode.

The variable is named IS_HOSTED for historical reasons; it is read in exactly two places, both about storage, and setting it does not turn on multi-tenancy or anything else. Read it as "storage is not publicly readable".

Technical Note

Proxy mode uses Next.js Server Actions instead of Route Handlers. This is because Next.js App Router Route Handlers have a hardcoded 1MB body size limit that cannot be configured. Server Actions support configurable body size limits via experimental.serverActions.bodySizeLimit in next.config.mjs.

Alternative: Public Endpoint URL​

If you prefer direct browser-to-storage uploads (better performance for large files), you can expose the storage publicly and set AWS_PUBLIC_ENDPOINT_URL instead:

AWS_PUBLIC_ENDPOINT_URL=https://yourdomain.com

This tells the app to generate presigned URLs using your public domain instead of the internal storage hostname. The Nginx reverse proxy included in the Docker setup routes /testplanit/ requests to the bundled storage automatically. This approach avoids the need for IS_HOSTED=true but requires the storage to be reachable through your reverse proxy.

Direct mode against AWS S3 requires a CORS policy​

In direct mode the browser sends the file to s3.amazonaws.com, a different origin from your instance, and the Content-Type header makes it a preflighted request. S3 rejects the preflight unless the bucket allows your origin:

aws s3api put-bucket-cors --bucket your-bucket-name --cors-configuration '{
"CORSRules": [{
"AllowedOrigins": ["https://testplanit.example.com"],
"AllowedMethods": ["PUT", "POST", "GET", "HEAD"],
"AllowedHeaders": ["*"],
"ExposeHeaders": ["ETag"],
"MaxAgeSeconds": 3000
}]
}'

This failure leaves nothing in the application logs — the browser blocks the request before it reaches your server — so check the browser console rather than the server when an upload fails silently.

Proxy mode needs no CORS rule at all: the browser only ever talks to your own origin.

Upload Flow​

  1. File Selection: User selects or drops files
  2. Validation: System checks file type and size limits
  3. Mode Detection: System determines direct vs proxy mode
  4. Upload:
    • Direct mode: Frontend requests presigned URL, uploads directly to storage
    • Proxy mode: Frontend calls server action, which uploads to storage
  5. Metadata Storage: File information stored in database
  6. Attachment Link: File linked to parent entity

File Size Limits​

Each upload type has its own ceiling:

Upload typeMaximum size
Attachments10 MB (configurable)
Inline document images10 MB (configurable)
Project icons4 MB
Avatars2 MB

A file over the limit is rejected before it is stored, with a message naming the maximum for that type.

Attachments and inline document images share one knob, UPLOAD_MAX_MB (default 10). Set it in the .env that Docker Compose reads, then restart:

# testplanit/.env
UPLOAD_MAX_MB=100
docker compose -f docker-compose.prod.yml up -d prod

No rebuild is needed, on an image you built or one you pulled. Next.js freezes the matching server-action body limit into the standalone build, so the container entrypoint rewrites that frozen value from UPLOAD_MAX_MB on every start — one variable drives both the transport limit and the per-file check. On Kubernetes, set config.uploadMaxMb in your Helm values instead. Project icons and avatars are deliberately fixed — they are UI thumbnails, not operator-sized user payloads.

The bundled nginx separately caps any request body sent to the app at 10 MB. In proxy mode the file travels through the app server, so that ceiling applies to the upload as well — raising UPLOAD_MAX_MB alone is not enough. See nginx-local/README.md for how to raise it per deployment.

  • Total attachments: No limit per entity

Attachment Management​

Viewing Attachments​

Attachments appear in several locations:

  • Test Case Details: View all case attachments
  • Test Run Results: See evidence and screenshots
  • Session Results: View session documentation
  • Rich Text Content: Inline images and documents

Download and Access​

  • Direct Download: Click attachment name to download
  • Preview: Supported file types show inline preview (see Inline Previews)
  • Secure Access: All downloads use signed URLs with expiration
  • Permission Checks: Access based on entity permissions

Inline Previews​

Many attachment types render inline rather than only downloading:

  • Images and PDFs render inline on an entity's details view and open in a large-preview carousel. The carousel is keyboard-navigable — the left/right arrow keys move between attachments and Escape closes it.
  • Word (.docx), Excel (.xlsx, .xls), and PowerPoint (.pptx) documents render in the browser: Word and Excel as formatted HTML, and PowerPoint slide-by-slide with previous/next controls.
  • Text and Markdown files show their contents inline, with Markdown formatted and fenced code blocks syntax-highlighted.

Full Office documents render only in the large preview (details view and carousel); in the compact attachment-count list they stay icon-only, so listing many attachments stays fast.

An Office document falls back to a generic file icon with a download link when it can't be previewed — files larger than 25 MB, empty spreadsheets, and legacy binary .doc and .ppt files, which have no client-side renderer.

Deleting Attachments​

Users with appropriate permissions can:

  1. Click the delete (×) button next to attachment
  2. Confirm deletion in dialog
  3. File removed from both storage and database
warning

Deleted attachments cannot be recovered. Ensure you have backups if needed.

Security Features​

Access Control​

  • Permission-Based: Access follows entity permissions
  • Signed URLs: All file access uses temporary signed URLs
  • Expiration: Download links expire after 1 hour
  • Authentication: Requires valid user session

File Validation​

  • Type Checking: MIME type validation on upload
  • Size Limits: Configurable maximum file sizes
  • Malware Scanning: Optional virus scanning integration
  • Content Filtering: Blocks potentially harmful file types

API Reference​

Get Upload URL​

POST /api/get-attachment-url
Content-Type: application/json

{
"fileName": "screenshot.png",
"fileSize": 1024000,
"contentType": "image/png"
}

Response:

{
"uploadUrl": "https://s3.amazonaws.com/bucket/file?signature=...",
"fileKey": "attachments/uuid/screenshot.png"
}

File Downloads​

Files are accessed directly through signed URLs generated during upload. The system does not provide a separate download API endpoint - instead, files are downloaded directly from storage using the signed URLs provided during the upload process.

Direct Download:

  • Files are accessed via signed URLs from storage
  • URLs are generated with appropriate expiration times
  • Access is controlled through the original upload permissions

Troubleshooting​

Upload Failures​

Issue: Files fail to upload Solutions:

  • Check file size against limits
  • Verify file type is supported
  • Ensure storage backend is accessible
  • Check network connectivity

Access Denied Errors​

Issue: Cannot download attachments Solutions:

  • Verify user has access to parent entity
  • Check if attachment still exists in storage
  • Ensure storage credentials are valid
  • Review bucket permissions

Broken Images (400 from /_next/image)​

Issue: Project icons or avatars are broken in some places (e.g. the header Projects dropdown) but fine in others (e.g. Admin → Projects). The browser console shows repeated Failed to load resource: 400 for image.

Cause: Next.js's image optimizer is on and rejecting your storage host. It only serves URLs whose host+path are in next.config's remotePatterns allowlist, which is baked at build time from BASE_DOMAIN. If BASE_DOMAIN didn't reach the build (or doesn't match your real domain), /_next/image returns 400. Components that render through <ProjectIcon> (Admin) set unoptimized and bypass the optimizer, which is why they still work — that's the "some places" symptom.

Solutions:

  • Recommended: rebuild with SELF_HOSTED=true (the compose default) so the optimizer is off and images load straight from storage on any domain. It must be a build arg — a runtime .env.production value is read too late and has no effect. See Images and custom domains.
  • Confirm what the running image actually baked (this is authoritative — not a browser cache issue if it says false):
    docker exec testplanit-prod \
    grep -o '"unoptimized":[a-z]*' /app/testplanit/.next/required-server-files.json
    # → "unoptimized":true on a correct self-hosted build
  • If you want the optimizer on instead, rebuild with SELF_HOSTED=false and BASE_DOMAIN=<your real domain>.
  • After a correct rebuild, hard-refresh (Cmd/Ctrl+Shift+R) to drop cached 400s.

Storage Configuration​

Issue: Storage backend not working Solutions:

  • Verify environment variables are set
  • Test storage connectivity
  • Check bucket exists and is accessible
  • Review storage credentials

Performance Issues​

Issue: Slow upload/download speeds Solutions:

  • Check network bandwidth
  • Consider using CDN for downloads
  • Optimize file sizes before upload
  • Monitor storage backend performance

Best Practices​

For Users​

  1. File Organization: Use descriptive file names
  2. Size Optimization: Compress large images before upload
  3. File Types: Use appropriate formats for content
  4. Security: Don't upload sensitive information

For Administrators​

  1. Storage Monitoring: Monitor storage usage and costs
  2. Backup Strategy: Implement regular backups
  3. Access Policies: Review and update bucket policies
  4. Performance: Monitor upload/download metrics
  5. Security: Regular security audits of storage access

Advanced Configuration​

Custom Storage Backends​

TestPlanIt can be extended to support additional storage backends by implementing the storage interface.

CDN Integration​

For better performance, configure CloudFront or similar CDN:

# CDN Configuration
AWS_CLOUDFRONT_DOMAIN=d123456789.cloudfront.net
AWS_CLOUDFRONT_DISTRIBUTION_ID=E123456789ABCD

Backup and Archival​

Consider implementing:

  • Automated backups of attachment storage
  • Lifecycle policies for old attachments
  • Cross-region replication for disaster recovery
  • Archive storage for infrequently accessed files