How to Configure Media Storage with S3 in Listmonk: A Complete Setup Guide
Set upload.provider = "s3" in your listmonk.toml configuration file and populate the [upload.s3] block with your AWS credentials, bucket name, and region to store media files directly in Amazon S3, with automatic support for private buckets via pre-signed URLs.
Listmonk stores uploaded media—campaign images, subscriber attachments, and template assets—through a pluggable storage abstraction. By configuring the S3 provider, you replace local filesystem storage with Amazon S3 or any S3-compatible service. This guide explains the configuration structure defined in models/settings.go, the initialization logic in cmd/init.go, and the operational methods implemented in internal/media/providers/s3/s3.go.
Understanding the Media Store Architecture
The storage system relies on the media.Store interface defined in internal/media/media.go. At startup, the initMediaStore function in cmd/init.go inspects the upload.provider setting. When set to "s3", Listmonk instantiates an S3 client using the third-party simples3 library.
The provider creates an s3.Client that satisfies the media.Store interface by implementing four core methods: Put for uploads, GetURL for URL generation, GetBlob for downloads, and Delete for removal.
Required S3 Configuration Fields
All S3-specific options reside under the [upload.s3] namespace in your configuration file, mapped to the s3.Opt struct:
provider– Must be set to"s3"to enable the S3 store.url– Endpoint URL (defaults tohttps://s3.<region>.amazonaws.com).public_url– Public-facing base URL; leave empty for private buckets or set to/media/to proxy through Listmonk.aws_access_key_id&aws_secret_access_key– IAM credentials. If omitted, Listmonk falls back to the EC2 instance role.aws_default_region– AWS region identifier (e.g.,us-east-1).bucket– Target S3 bucket name.bucket_path– Optional prefix path where files are stored (e.g.,"uploads/campaigns").bucket_type–"public"forpublic-readACLs or"private"for signed URLs.expiry– Pre-signed URL duration for private objects (default"168h"for 7 days).
Startup Initialization Process
During application bootstrap in cmd/init.go, the initMediaStore function handles provider selection:
// cmd/init.go (excerpt)
case "s3":
var o s3.Opt
ko.Unmarshal("upload.s3", &o)
o.RootURL = ko.String("app.root_url")
up, err := s3.NewS3Store(o)
NewS3Store constructs a simples3.S3 client configured with your credentials and endpoint, then returns a *s3.Client that implements media.Store.
S3 Store Operations
The s3.Client in internal/media/providers/s3/s3.go handles all object interactions:
Uploading Files with Put
The Put(name, contentType, file) method uploads objects via simples3.FilePut. If bucket_type equals "public", it applies the public-read ACL. The method uses makeBucketPath to prepend the configured bucket_path prefix to the object key.
Generating Access URLs with GetURL
GetURL(name) returns a usable URL for frontend access. For private buckets, it generates a pre-signed URL using the configured expiry duration. For public buckets, it returns either the public_url value or constructs a standard S3 URL.
Retrieving Object Data with GetBlob
GetBlob(url) downloads raw bytes for a given filename, resolving the object key via makeBucketPath and calling simples3.FileDownload. This powers the private media proxy feature.
Deleting Objects with Delete
Delete(name) removes the object from S3 using simples3.FileDelete, automatically applying the bucket_path prefix.
Serving Private Bucket Content
When public_url is configured as a relative path (e.g., /media/), Listmonk registers a proxy handler in cmd/media.go called ServeS3Media. This handler:
- Intercepts requests to
GET /media/:filepath - Uses
GetBlobto fetch the object from S3 (generating signed credentials internally) - Streams the content to the client without exposing the direct S3 URL
This approach allows you to keep your bucket completely private while still serving images and attachments through your Listmonk domain.
Complete Configuration Example
Add the following to your listmonk.toml file:
[app]
root_url = "https://mail.example.com"
[upload]
provider = "s3"
[upload.s3]
url = "https://s3.us-east-1.amazonaws.com"
public_url = "/media/" # Proxy through Listmonk; use "" for direct S3 URLs
aws_access_key_id = "AKIAIOSFODNN7EXAMPLE"
aws_secret_access_key = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
aws_default_region = "us-east-1"
bucket = "listmonk-production-media"
bucket_path = "uploads"
bucket_type = "private"
expiry = "168h"
After restarting Listmonk, the server logs media upload provider: s3 and all media uploads persist to the specified bucket.
Programmatic Access Example
For custom tooling or migration scripts, you can instantiate the store directly:
package main
import (
"os"
"time"
"github.com/knadh/listmonk/internal/media/providers/s3"
)
func main() {
opt := s3.Opt{
URL: "https://s3.us-east-1.amazonaws.com",
PublicURL: "",
AccessKey: os.Getenv("AWS_KEY"),
SecretKey: os.Getenv("AWS_SECRET"),
Region: "us-east-1",
RootURL: "https://mail.example.com",
Bucket: "listmonk-media",
BucketPath: "attachments",
BucketType: "private",
Expiry: 168 * time.Hour,
}
store, err := s3.NewS3Store(opt)
if err != nil {
panic(err)
}
// Upload file
f, _ := os.Open("newsletter.png")
name, _ := store.Put("newsletter.png", "image/png", f)
f.Close()
// Get signed URL
url := store.GetURL(name)
println("Signed URL:", url)
}
Summary
- Configuration: Set
upload.provider = "s3"and populate the[upload.s3]block with credentials, region, and bucket details defined inmodels/settings.go. - Initialization: The
initMediaStorefunction incmd/init.gocreates the client vias3.NewS3Storeusing thes3.Optstruct. - Privacy: Use
bucket_type = "private"withexpirysettings for signed URLs, or setpublic_urlto/media/to proxy content through Listmonk'sServeS3Mediahandler. - Flexibility: The implementation in
internal/media/providers/s3/s3.gosupports AWS IAM roles (when credentials are omitted) and custom endpoints for S3-compatible services.
Frequently Asked Questions
Can I use S3-compatible services like MinIO or Wasabi instead of AWS?
Yes. Set the url field to your custom endpoint (e.g., https://minio.example.com) and provide the access keys. The simples3 library used in internal/media/providers/s3/s3.go supports any S3-compatible API, allowing you to use DigitalOcean Spaces, Backblaze B2, or self-hosted MinIO instances.
What happens if I omit the AWS access keys in the configuration?
Listmonk will automatically attempt to authenticate using the EC2 instance's IAM role or the standard AWS credential chain. This is handled in s3.NewS3Store by passing empty credentials to the underlying simples3 client, which then relies on environment credentials or instance metadata.
How do I serve private bucket files through my Listmonk domain instead of direct S3 URLs?
Configure public_url as a relative path starting with /, such as /media/. When Listmonk detects this pattern during initialization in cmd/init.go, it registers the ServeS3Media proxy handler. This endpoint fetches objects via GetBlob (generating signed URLs internally) and streams them to clients while keeping the actual S3 bucket URLs hidden.
What is the maximum expiry duration for pre-signed URLs?
The expiry field accepts Go duration strings (e.g., 168h for 7 days). While AWS S3 technically supports pre-signed URLs up to 7 days (604800 seconds), you should verify your specific S3-compatible provider's limits. Listmonk passes this duration directly to the simples3 signing logic in GetURL.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →