Skip to main content
Deploy Kan with PostgreSQL and MinIO (S3-compatible storage) using Docker Compose, with clear steps and production notes.

What you’ll set up

Kan

Kan web app (Next.js), on port 3000.

PostgreSQL 15

PostgreSQL database for Kan.

MinIO (S3-compatible)

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.
1

Set environment variables

Provide the minimum required configuration (local example):
NEXT_PUBLIC_BASE_URL=http://kan.localtest.me:3000
BETTER_AUTH_SECRET=<long random string>
POSTGRES_URL=postgresql://kan:<password>@postgres:5432/kan_db

# MinIO/S3
S3_ENDPOINT=http://s3.localtest.me:9000
S3_ACCESS_KEY_ID=<minio-access-key>
S3_SECRET_ACCESS_KEY=<minio-secret-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.
2

Create or review Docker Compose files

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.
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
3

Start services

docker compose up -d
Then open:
4

Initialize MinIO

  1. Log into the MinIO Console (http://minio.localtest.me:9001).
  1. Create a bucket (e.g., kan).
  2. For simple public avatars, apply a read-only policy so GET requests are allowed for objects:
{
  "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.
5

Verify the setup

  • 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

  • Local
  • Production

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.

Make the bucket public (read-only) with mc

Use your MinIO root credentials to allow anonymous reads:
# Replace with your MINIO_ROOT_PASSWORD
MINIO_PASS='<your-minio-password>'

# 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:
policy.json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "PublicReadGetObject",
      "Effect": "Allow",
      "Principal": "*",
      "Action": ["s3:GetObject"],
      "Resource": ["arn:aws:s3:::kan/*"]
    }
  ]
}
# Use your MinIO root credentials
MINIO_PASS='<your-minio-password>'

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):
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