---
title: "Kan + MinIO (S3)"
mode: "wide"
tag: "NEW"
---
Deploy Kan with PostgreSQL and MinIO (S3-compatible storage) using Docker Compose, with clear steps and production notes.
## What you’ll set up
Kan web app (Next.js), on port 3000.
PostgreSQL database for Kan.
MinIO object storage (console port 9001, S3 API port 9000).
## How it works
- Kan stores data in PostgreSQL.
- Kan uploads files (e.g., avatars) to MinIO over the S3 API.
- The browser fetches public files directly from MinIO’s public URL.
- The Next.js image optimizer in Kan must be explicitly allowed to fetch from your storage host.
Key domain settings:
- NEXT_PUBLIC_BASE_URL → the Kan site
- NEXT_PUBLIC_STORAGE_URL → the public S3 base URL
- NEXT_PUBLIC_STORAGE_DOMAIN → the exact S3 hostname (no scheme)
## Prerequisites
- Docker and Docker Compose
- Open local ports: 3000 (Kan), 5432 (Postgres), 9000/9001 (MinIO)
- A long random string for BETTER_AUTH_SECRET (32+ chars)
For production you’ll want a reverse proxy (Traefik/Nginx/Caddy), valid TLS
certificates, and DNS for your domains (e.g., kan.example.com,{" "}
s3.example.com).
## Quick start
Why localtest.me? It resolves to 127.0.0.1{" "}
automatically, so you can test domain-based configs locally without editing
hosts.
Provide the minimum required configuration (local example):
```bash
NEXT_PUBLIC_BASE_URL=http://kan.localtest.me:3000
BETTER_AUTH_SECRET=
POSTGRES_URL=postgresql://kan:@postgres:5432/kan_db
# MinIO/S3
S3_ENDPOINT=http://s3.localtest.me:9000
S3_ACCESS_KEY_ID=
S3_SECRET_ACCESS_KEY=
S3_REGION=none
S3_FORCE_PATH_STYLE=true
# Public storage access
NEXT_PUBLIC_STORAGE_URL=http://s3.localtest.me:9000
NEXT_PUBLIC_STORAGE_DOMAIN=s3.localtest.me
NEXT_PUBLIC_AVATAR_BUCKET_NAME=kan
```
Issue #109 fix: make sure NEXT_PUBLIC_STORAGE_DOMAIN exactly
equals the hostname that serves your images (no scheme, no port).
Optional (see README for full list): Email (`EMAIL_FROM`, `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASSWORD`, `SMTP_SECURE`), OAuth/OIDC (`GOOGLE_*`, `GITHUB_*`, `OIDC_*`), auth toggles, Trello import, etc.
You can start from the minimal compose at the repository root (docker-compose.yml) and review the production-oriented settings in cloud/docker-compose.yml.
Start with the minimal setup (web + postgres + minio) and ensure environment variables are passed to the web service.
```yaml
services:
postgres:
image: postgres:15
container_name: kan-db
ports:
- "5432:5432"
environment:
POSTGRES_USER: kan
POSTGRES_PASSWORD: changeme
POSTGRES_DB: kan_db
volumes:
- pg_data:/var/lib/postgresql/data
restart: unless-stopped
minio:
image: minio/minio:latest
container_name: kan-minio
command: server /data --console-address ":9001"
ports:
- "9000:9000" # S3 API
- "9001:9001" # Console
environment:
# Use the same credentials in your .env
# as S3_ACCESS_KEY_ID / S3_SECRET_ACCESS_KEY
MINIO_ROOT_USER: minio
MINIO_ROOT_PASSWORD: minio123456789
volumes:
- minio_data:/data
restart: unless-stopped
web:
image: ghcr.io/kanbn/kan:latest
container_name: kan-web
depends_on:
- postgres
- minio
ports:
- "3000:3000"
# Load variables from .env
# (see the "Set environment variables" step)
env_file:
- .env
restart: unless-stopped
volumes:
pg_data:
minio_data:
```
Ensure your .env contains values that match this compose file.
For example:
-
POSTGRES_URL=postgresql://kan:changeme@postgres:5432/kan_db
-
S3_ENDPOINT=http://s3.localtest.me:9000
-
S3_ACCESS_KEY_ID=minio and{" "}
S3_SECRET_ACCESS_KEY=minio123456789
-
S3_FORCE_PATH_STYLE=true
-
NEXT_PUBLIC_STORAGE_URL=http://s3.localtest.me:9000
-
NEXT_PUBLIC_STORAGE_DOMAIN=s3.localtest.me
-
NEXT_PUBLIC_AVATAR_BUCKET_NAME=kan
```bash
docker compose up -d
```
Then open:
1) Log into the MinIO Console (http://minio.localtest.me:9001).
2. Create a bucket (e.g., kan).
3. For simple public avatars, apply a read-only policy so GET requests are allowed for objects:
```json
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": { "AWS": ["*"] },
"Action": ["s3:GetBucketLocation", "s3:ListBucket"],
"Resource": ["arn:aws:s3:::kan"]
},
{
"Effect": "Allow",
"Principal": { "AWS": ["*"] },
"Action": ["s3:GetObject"],
"Resource": ["arn:aws:s3:::kan/*"]
}
]
}
```
Alternatively, keep the bucket private and use presigned URLs. In that case,
ensure your server and browser access paths are correctly configured.
- Sign in to Kan and upload an avatar (Settings).
- Confirm the object is created in your MinIO bucket.
- The avatar should render without errors.
If you see a 400 from /\_next/image with “url parameter is not allowed”:
-
NEXT_PUBLIC_STORAGE_DOMAIN must exactly match the S3 hostname
that serves images.
-
NEXT_PUBLIC_STORAGE_URL should use the same host (with
scheme/port).
- Ensure you’re using the latest Kan image.
## Production setup
NEXT_PUBLIC_BASE_URL=http://kan.localtest.me:3000
S3_ENDPOINT=http://s3.localtest.me:9000
NEXT_PUBLIC_STORAGE_URL=http://s3.localtest.me:9000
NEXT_PUBLIC_STORAGE_DOMAIN=s3.localtest.me
- Keep
S3_FORCE_PATH_STYLE=true for MinIO.
NEXT_PUBLIC_BASE_URL=https://kan.example.com
S3_ENDPOINT=https://s3.example.com
NEXT_PUBLIC_STORAGE_URL=https://s3.example.com
NEXT_PUBLIC_STORAGE_DOMAIN=s3.example.com
- Keep
S3_FORCE_PATH_STYLE=true for MinIO.
- Put Kan and MinIO behind HTTPS with a reverse proxy (Traefik/Nginx) and valid TLS.
## Troubleshooting
- If public: confirm GET is allowed on objects (bucket policy).
- If private: ensure presigned URLs are generated and valid.
- 403 AccessDenied indicates permissions, not CORS. CORS is not required for simple
<img> GETs.
Use your MinIO root credentials to allow anonymous reads:
```bash
# Replace with your MINIO_ROOT_PASSWORD
MINIO_PASS=''
# Point mc at MinIO via the container network (no ports required)
docker run --rm --network container:kan-minio minio/mc \
mc alias set local http://127.0.0.1:9000 minio "$MINIO_PASS"
# Allow public downloads from the bucket
docker run --rm --network container:kan-minio minio/mc \
mc anonymous set download local/kan
# Optional: verify anonymous status
docker run --rm --network container:kan-minio minio/mc \
mc anonymous get local/kan
```
If you prefer a bucket policy, apply a public-read policy for objects:
```json policy.json
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "PublicReadGetObject",
"Effect": "Allow",
"Principal": "*",
"Action": ["s3:GetObject"],
"Resource": ["arn:aws:s3:::kan/*"]
}
]
}
```
```bash
# Use your MinIO root credentials
MINIO_PASS=''
docker run --rm --network container:kan-minio \
-e AWS_ACCESS_KEY_ID=minio \
-e AWS_SECRET_ACCESS_KEY="$MINIO_PASS" \
-e AWS_DEFAULT_REGION=us-east-1 -e AWS_S3_FORCE_PATH_STYLE=true \
-v "$PWD:/work" amazon/aws-cli \
s3api put-bucket-policy --bucket kan --policy file:///work/policy.json \
--endpoint-url http://127.0.0.1:9000
```
-
Exact match on
NEXT_PUBLIC_STORAGE_DOMAIN with your storage
host.
-
Same host in
NEXT_PUBLIC_STORAGE_URL.
- Update to the latest Kan image.
- This means Next.js accepted the URL, but the upstream returned a non-image (e.g., 403 HTML/XML).
- Fix: make the bucket/object publicly readable (see above), or use presigned URLs.
- Sanity test from the web network (replace with your image URL):
```bash
IMG_URL="https://s3.example.com/kan/path/to/avatar.jpg"
# Headers/content-type as seen from the app network
docker run --rm --network container:kan-web curlimages/curl:8.9.1 \
-I -L --max-redirs 5 "$IMG_URL"
# Quick status + content-type summary
docker run --rm --network container:kan-web curlimages/curl:8.9.1 \
-s -o /dev/null -w "HTTP:%{http_code} CT:%{content_type} URL:%{url_effective}\n" -L "$IMG_URL"
```
- The Kan container must reach
S3_ENDPOINT.
- Verify DNS/ports inside the container (e.g.,
docker exec -it <kan-container> sh).
## References
- [Kan README](https://github.com/kanbn/kan/blob/main/README.md)
- [Cloud compose reference](https://github.com/kanbn/kan/blob/main/cloud/docker-compose.yml)
- [Kan #109 Issue](https://github.com/kanbn/kan/issues/109)