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 to https://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" for public-read ACLs 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:

  1. Intercepts requests to GET /media/:filepath
  2. Uses GetBlob to fetch the object from S3 (generating signed credentials internally)
  3. 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 in models/settings.go.
  • Initialization: The initMediaStore function in cmd/init.go creates the client via s3.NewS3Store using the s3.Opt struct.
  • Privacy: Use bucket_type = "private" with expiry settings for signed URLs, or set public_url to /media/ to proxy content through Listmonk's ServeS3Media handler.
  • Flexibility: The implementation in internal/media/providers/s3/s3.go supports 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:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →