> ## Documentation Index
> Fetch the complete documentation index at: https://docs.kan.bn/llms.txt
> Use this file to discover all available pages before exploring further.

# Kan + MinIO (S3)

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

## What you’ll set up

<CardGroup cols={2}>
  <Card title="Kan" icon="globe" color="#0284c7" horizontal>
    Kan web app (Next.js), on port 3000.
  </Card>

  <Card title="PostgreSQL 15" icon="database" color="#65a30d" horizontal>
    PostgreSQL database for Kan.
  </Card>
</CardGroup>

<Card title="MinIO (S3-compatible)" icon="cloud" color="#ca8a04" horizontal>
  MinIO object storage (console port 9001, S3 API port 9000).
</Card>

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

* <code>NEXT\_PUBLIC\_BASE\_URL</code> → the Kan site
* <code>NEXT\_PUBLIC\_STORAGE\_URL</code> → the public S3 base URL
* <code>NEXT\_PUBLIC\_STORAGE\_DOMAIN</code> → 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 <code>BETTER\_AUTH\_SECRET</code> (32+ chars)

<Note type="warning">
  For production you’ll want a reverse proxy (Traefik/Nginx/Caddy), valid TLS
  certificates, and DNS for your domains (e.g., <code>kan.example.com</code>,{" "}
  <code>s3.example.com</code>).
</Note>

## Quick start

<Tip type="info">
  Why <code>localtest.me</code>? It resolves to <code>127.0.0.1</code>{" "}
  automatically, so you can test domain-based configs locally without editing
  hosts.
</Tip>

<Steps>
  <Step title="Set environment variables">
    Provide the minimum required configuration (local example):

    ```bash theme={null}
    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
    ```

    <Note type="info">
      Issue #109 fix: make sure <code>NEXT\_PUBLIC\_STORAGE\_DOMAIN</code> exactly
      equals the hostname that serves your images (no scheme, no port).
    </Note>

    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.
  </Step>

  <Step title="Create or review Docker Compose files">
    You can start from the minimal compose at the repository root (<code>docker-compose.yml</code>) and review the production-oriented settings in <code>cloud/docker-compose.yml</code>.

    Start with the minimal setup (web + postgres + minio) and ensure environment variables are passed to the web service.

    <Accordion title="Docker Compose example">
      ```yaml theme={null}
      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:
      ```

      <Note type="info">
        Ensure your <code>.env</code> contains values that match this compose file.
        For example:

        <ul>
          <li>
            <code>POSTGRES\_URL=postgresql://kan:changeme\@postgres:5432/kan\_db</code>
          </li>

          <li>
            <code>S3\_ENDPOINT=[http://s3.localtest.me:9000](http://s3.localtest.me:9000)</code>
          </li>

          <li>
            <code>S3\_ACCESS\_KEY\_ID=minio</code> and{" "}
            <code>S3\_SECRET\_ACCESS\_KEY=minio123456789</code>
          </li>

          <li>
            <code>S3\_FORCE\_PATH\_STYLE=true</code>
          </li>

          <li>
            <code>NEXT\_PUBLIC\_STORAGE\_URL=[http://s3.localtest.me:9000](http://s3.localtest.me:9000)</code>
          </li>

          <li>
            <code>NEXT\_PUBLIC\_STORAGE\_DOMAIN=s3.localtest.me</code>
          </li>

          <li>
            <code>NEXT\_PUBLIC\_AVATAR\_BUCKET\_NAME=kan</code>
          </li>
        </ul>
      </Note>
    </Accordion>
  </Step>

  <Step title="Start services">
    ```bash theme={null}
    docker compose up -d
    ```

    Then open:

    <ul>
      <li>
        Kan: <a href="http://kan.localtest.me:3000">[http://kan.localtest.me:3000](http://kan.localtest.me:3000)</a>
      </li>

      <li>
        MinIO Console:{" "}
        <a href="http://minio.localtest.me:9001">[http://minio.localtest.me:9001](http://minio.localtest.me:9001)</a>
      </li>

      <li>
        MinIO S3 API:{" "}
        <a href="http://s3.localtest.me:9000">[http://s3.localtest.me:9000](http://s3.localtest.me:9000)</a>
      </li>
    </ul>
  </Step>

  <Step title="Initialize MinIO">
    1. Log into the MinIO Console ([http://minio.localtest.me:9001](http://minio.localtest.me:9001)).

    2) Create a bucket (e.g., <code>kan</code>).

    3) For simple public avatars, apply a read-only policy so GET requests are allowed for objects:

    ```json theme={null}
    {
      "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/*"]
        }
      ]
    }
    ```

    <Note type="warning">
      Alternatively, keep the bucket private and use presigned URLs. In that case,
      ensure your server and browser access paths are correctly configured.
    </Note>
  </Step>

  <Step title="Verify the setup">
    <ul>
      <li>Sign in to Kan and upload an avatar (Settings).</li>
      <li>Confirm the object is created in your MinIO bucket.</li>
      <li>The avatar should render without errors.</li>
    </ul>

    If you see a 400 from <code>/\_next/image</code> with “url parameter is not allowed”:

    <ul>
      <li>
        <code>NEXT\_PUBLIC\_STORAGE\_DOMAIN</code> must exactly match the S3 hostname
        that serves images.
      </li>

      <li>
        <code>NEXT\_PUBLIC\_STORAGE\_URL</code> should use the same host (with
        scheme/port).
      </li>

      <li>Ensure you’re using the latest Kan image.</li>
    </ul>
  </Step>
</Steps>

## Production setup

<Tabs>
  <Tab title="Local">
    <ul>
      <li><code>NEXT\_PUBLIC\_BASE\_URL=[http://kan.localtest.me:3000](http://kan.localtest.me:3000)</code></li>
      <li><code>S3\_ENDPOINT=[http://s3.localtest.me:9000](http://s3.localtest.me:9000)</code></li>
      <li><code>NEXT\_PUBLIC\_STORAGE\_URL=[http://s3.localtest.me:9000](http://s3.localtest.me:9000)</code></li>
      <li><code>NEXT\_PUBLIC\_STORAGE\_DOMAIN=s3.localtest.me</code></li>
      <li>Keep <code>S3\_FORCE\_PATH\_STYLE=true</code> for MinIO.</li>
    </ul>
  </Tab>

  <Tab title="Production">
    <ul>
      <li><code>NEXT\_PUBLIC\_BASE\_URL=[https://kan.example.com](https://kan.example.com)</code></li>
      <li><code>S3\_ENDPOINT=[https://s3.example.com](https://s3.example.com)</code></li>
      <li><code>NEXT\_PUBLIC\_STORAGE\_URL=[https://s3.example.com](https://s3.example.com)</code></li>
      <li><code>NEXT\_PUBLIC\_STORAGE\_DOMAIN=s3.example.com</code></li>
      <li>Keep <code>S3\_FORCE\_PATH\_STYLE=true</code> for MinIO.</li>
      <li>Put Kan and MinIO behind HTTPS with a reverse proxy (Traefik/Nginx) and valid TLS.</li>
    </ul>
  </Tab>
</Tabs>

## Troubleshooting

<AccordionGroup>
  <Accordion title="Files upload but don’t display">
    <ul>
      <li>If public: confirm GET is allowed on objects (bucket policy).</li>
      <li>If private: ensure presigned URLs are generated and valid.</li>
      <li>403 AccessDenied indicates permissions, not CORS. CORS is not required for simple <code>\<img></code> GETs.</li>
    </ul>
  </Accordion>

  <Accordion title="Make the bucket public (read-only) with mc" defaultOpen>
    Use your MinIO root credentials to allow anonymous reads:

    ```bash theme={null}
    # 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
    ```
  </Accordion>

  <Accordion title="Alternative: S3 bucket policy (AWS CLI)">
    If you prefer a bucket policy, apply a public-read policy for objects:

    ```json policy.json theme={null}
    {
      "Version": "2012-10-17",
      "Statement": [
        {
          "Sid": "PublicReadGetObject",
          "Effect": "Allow",
          "Principal": "*",
          "Action": ["s3:GetObject"],
          "Resource": ["arn:aws:s3:::kan/*"]
        }
      ]
    }
    ```

    ```bash theme={null}
    # 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
    ```
  </Accordion>

  <Accordion title="Next.js optimizer 400 — url parameter is not allowed">
    <ul>
      <li>
        Exact match on <code>NEXT\_PUBLIC\_STORAGE\_DOMAIN</code> with your storage
        host.
      </li>

      <li>
        Same host in <code>NEXT\_PUBLIC\_STORAGE\_URL</code>.
      </li>

      <li>Update to the latest Kan image.</li>
    </ul>
  </Accordion>

  <Accordion title="Next/Image: “url parameter is valid but upstream response is invalid”">
    <ul>
      <li>This means Next.js accepted the URL, but the upstream returned a non-image (e.g., 403 HTML/XML).</li>
      <li>Fix: make the bucket/object publicly readable (see above), or use presigned URLs.</li>
      <li>Sanity test from the web network (replace with your image URL):</li>
    </ul>

    ```bash theme={null}
    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"
    ```
  </Accordion>

  <Accordion title="Connectivity checks">
    <ul>
      <li>The Kan container must reach <code>S3\_ENDPOINT</code>.</li>
      <li>Verify DNS/ports inside the container (e.g., <code>docker exec -it \<kan-container> sh</code>).</li>
    </ul>
  </Accordion>
</AccordionGroup>

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