# How to Configure Media Storage with S3 in Listmonk: A Complete Setup Guide

> Learn how to configure media storage with S3 in Listmonk. Store media files directly in Amazon S3 using pre-signed URLs with this complete setup guide.

- Repository: [Kailash Nadh/listmonk](https://github.com/knadh/listmonk)
- Tags: how-to-guide
- Published: 2026-05-19

---

**Set `upload.provider = "s3"` in your [`listmonk.toml`](https://github.com/knadh/listmonk/blob/main/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`](https://github.com/knadh/listmonk/blob/main/models/settings.go), the initialization logic in [`cmd/init.go`](https://github.com/knadh/listmonk/blob/main/cmd/init.go), and the operational methods implemented in [`internal/media/providers/s3/s3.go`](https://github.com/knadh/listmonk/blob/main/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`](https://github.com/knadh/listmonk/blob/main/internal/media/media.go). At startup, the `initMediaStore` function in [`cmd/init.go`](https://github.com/knadh/listmonk/blob/main/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`](https://github.com/knadh/listmonk/blob/main/cmd/init.go), the `initMediaStore` function handles provider selection:

```go
// 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`](https://github.com/knadh/listmonk/blob/main/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`](https://github.com/knadh/listmonk/blob/main/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`](https://github.com/knadh/listmonk/blob/main/listmonk.toml) file:

```toml
[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:

```go
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`](https://github.com/knadh/listmonk/blob/main/models/settings.go).
- **Initialization**: The `initMediaStore` function in [`cmd/init.go`](https://github.com/knadh/listmonk/blob/main/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`](https://github.com/knadh/listmonk/blob/main/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`](https://github.com/knadh/listmonk/blob/main/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`](https://github.com/knadh/listmonk/blob/main/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`.