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
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.
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:
- Drag and Drop: Drag files directly onto attachment areas
- File Browser: Click "Choose Files" to open file picker
- Rich Text Editor: Paste images directly into rich text fields
- Bulk Upload: Select multiple files at once
External Links as Attachments
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:
- Frontend requests a presigned URL from the API
- Browser uploads directly to storage using the presigned URL
- No file data passes through the Next.js server
This is the most efficient mode as files upload directly to storage.
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
- Browser sends file to a Next.js server action
- Server action uploads the file to storage internally
- 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
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".
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
- File Selection: User selects or drops files
- Validation: System checks file type and size limits
- Mode Detection: System determines direct vs proxy mode
- Upload:
- Direct mode: Frontend requests presigned URL, uploads directly to storage
- Proxy mode: Frontend calls server action, which uploads to storage
- Metadata Storage: File information stored in database
- Attachment Link: File linked to parent entity
File Size Limits
Each upload type has its own ceiling:
| Upload type | Maximum size |
|---|---|
| Attachments | 10 MB (configurable) |
| Inline document images | 10 MB (configurable) |
| Project icons | 4 MB |
| Avatars | 2 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:
- Click the delete (×) button next to attachment
- Confirm deletion in dialog
- File removed from both storage and database
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.productionvalue 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=falseandBASE_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
- File Organization: Use descriptive file names
- Size Optimization: Compress large images before upload
- File Types: Use appropriate formats for content
- Security: Don't upload sensitive information
For Administrators
- Storage Monitoring: Monitor storage usage and costs
- Backup Strategy: Implement regular backups
- Access Policies: Review and update bucket policies
- Performance: Monitor upload/download metrics
- 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