puter Docker & Self-Hosted Deployment

GitHub

The Internet Computer! Free, Open-Source, and Self-Hostable.

RAW Compose
1. Pull Image (GHCR) docker pull ghcr.io/heyputer/puter
2. Docker Run (1-Click Launch) docker run -d --name puter -p 25:25 --restart always ghcr.io/heyputer/puter:main
3. Podman Run podman run -d --name puter -p 25:25 --restart always ghcr.io/heyputer/puter:main
4. Skopeo Inspect (Zero-Pull Manifest) skopeo inspect docker://ghcr.io/heyputer/puter:main
5. ORAS Pull (OCI Artifact) oras pull ghcr.io/heyputer/puter:main
6. Compose Deployment docker compose up -d
docker-compose.yml
Download .yml
---
# Self-hosted Puter โ€” full stack.
#
# Brings up Puter + every external service it needs:
#   - caddy    : reverse proxy (mirrors prod ALB; handles TLS + Host fan-out)
#   - valkey   : redis-compatible cache / rate-limiter backend
#   - mariadb  : SQL database (Puter applies its schema on first boot)
#   - dynamo   : DynamoDB-local (KV store; Puter creates the table itself)
#   - s3       : RustFS โ€” S3-compatible object storage
#   - s3-init  : one-shot init container that creates the bucket
#   - puter    : the application
#
# Quick start:
#   1. Copy .env.example to .env (or set the variables in your shell).
#   2. Drop a config.json into ./puter/config/ โ€” see selfhosted/full-stack.md
#      for the example that pairs with this compose.
#   3. docker compose up -d
#
# Easiest path:
#   curl -fsSL https://raw.githubusercontent.com/HeyPuter/puter/main/install.sh | sh
# grabs this file, generates secrets, writes .env + config.json, and runs
# the compose up for you.
#
# Production:
#   - Always replace the default passwords / S3 keys / Puter secrets.
#   - Enable TLS on the bundled Caddy (see caddy/Caddyfile) or front the
#     stack with your own TLS-terminating proxy.
#   - Move state-bearing volumes to a backed-up location.

services:
  valkey:
    image: valkey/valkey:8-alpine
    container_name: puter-valkey
    restart: unless-stopped
    # Run as a single-node cluster so Puter's ioredis Cluster client
    # (the only mode it speaks) can connect. On first boot we assign all
    # 16384 slots to ourselves; subsequent boots find them already in
    # nodes.conf and skip. `cluster-require-full-coverage no` keeps reads
    # working if we ever land partial slots.
    command:
      - sh
      - -c
      - |
        valkey-server \
          --port 6379 \
          --cluster-enabled yes \
          --cluster-config-file /data/nodes.conf \
          --cluster-node-timeout 5000 \
          --cluster-require-full-coverage no \
          --cluster-announce-ip valkey \
          --cluster-announce-port 6379 \
          --cluster-announce-bus-port 16379 \
          --appendonly yes \
          --save "60 1" &
        SERVER_PID=$$!
        until valkey-cli -p 6379 PING > /dev/null 2>&1; do sleep 0.5; done
        if ! valkey-cli -p 6379 CLUSTER NODES | grep -q '0-16383'; then
          valkey-cli -p 6379 CLUSTER ADDSLOTSRANGE 0 16383
        fi
        wait $$SERVER_PID
    volumes:
      # `:z` is an SELinux relabel hint for Fedora/RHEL hosts (no-op
      # everywhere else) โ€” without it those distros deny container
      # access to the bind mount and the service loops on EACCES.
      - ./puter/data/valkey:/data:z
    healthcheck:
      test:
        ["CMD-SHELL", "valkey-cli -p 6379 cluster info | grep -q cluster_state:ok"]
      interval: 5s
      timeout: 3s
      retries: 20
      start_period: 10s

  mariadb:
    image: mariadb:11
    container_name: puter-mariadb
    restart: unless-stopped
    environment:
      MARIADB_ROOT_PASSWORD: ${MARIADB_ROOT_PASSWORD:-root-change-me}
      MARIADB_DATABASE: ${MARIADB_DATABASE:-puter}
      MARIADB_USER: ${MARIADB_USER:-puter}
      MARIADB_PASSWORD: ${MARIADB_PASSWORD:-puter-change-me}
    volumes:
      - ./puter/data/mariadb:/var/lib/mysql:z
    healthcheck:
      # `healthcheck.sh` ships with the mariadb image; --connect verifies
      # the server is accepting auth, not just listening on the socket.
      test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
      interval: 5s
      timeout: 5s
      retries: 20
      start_period: 30s

  dynamo:
    # Puter creates the `store-kv-v1` table itself on startup
    # (config.dynamo.bootstrapTables = true does the work).
    image: amazon/dynamodb-local:latest
    container_name: puter-dynamo
    restart: unless-stopped
    user: "1000:1000"
    working_dir: /home/dynamodblocal
    command:
      - "-jar"
      - "DynamoDBLocal.jar"
      - "-sharedDb"
      - "-dbPath"
      - "/home/dynamodblocal/data"
    volumes:
      - ./puter/data/dynamo:/home/dynamodblocal/data:z

  s3:
    # RustFS โ€” S3-compatible object storage. Drop-in alternative:
    # MinIO (image: minio/minio, command: ["server", "/data", "--console-address", ":9001"]).
    image: rustfs/rustfs:latest
    container_name: puter-s3
    restart: unless-stopped
    environment:
      RUSTFS_ACCESS_KEY: ${S3_ACCESS_KEY:-puter}
      RUSTFS_SECRET_KEY: ${S3_SECRET_KEY:-puter-secret-change-me}
    volumes:
      - ./puter/data/s3:/data:z
    # Internal-only โ€” browsers reach RustFS via Caddy (`s3.<domain>`),
    # which preserves the Host header for S3 signature validation and
    # rides the same TLS termination as Puter. Uncomment to also expose
    # 9000 directly on the host for `aws-cli` / debugging.
    # ports:
    #   - "9000:9000"
    healthcheck:
      # RustFS exposes /health on the S3 port. Use wget (curl is not in
      # the slim image).
      test:
        [
          "CMD-SHELL",
          "wget -qO- --tries=1 --timeout=2 http://localhost:9000/health || exit 1",
        ]
      interval: 5s
      timeout: 3s
      retries: 20
      start_period: 5s

  s3-init:
    # One-shot container that creates the `puter-local` bucket and
    # applies its CORS rules on every boot (put-bucket-cors is
    # idempotent). Without CORS, browser uploads to presigned URLs
    # fail the preflight check.
    image: amazon/aws-cli:latest
    container_name: puter-s3-init
    depends_on:
      s3:
        condition: service_healthy
    environment:
      AWS_ACCESS_KEY_ID: ${S3_ACCESS_KEY:-puter}
      AWS_SECRET_ACCESS_KEY: ${S3_SECRET_KEY:-puter-secret-change-me}
      AWS_DEFAULT_REGION: us-east-1
    entrypoint:
      - /bin/sh
      - -c
      - |
        set -e
        endpoint=http://s3:9000
        bucket=${S3_BUCKET:-puter-local}
        if aws --endpoint-url "$$endpoint" s3api head-bucket --bucket "$$bucket" 2>/dev/null; then
          echo "bucket $$bucket already exists"
        else
          echo "creating bucket $$bucket"
          aws --endpoint-url "$$endpoint" s3 mb "s3://$$bucket"
        fi
        # Browser uploads (Dev Center deploy, puter.fs.upload) PUT to
        # presigned URLs cross-origin, so the bucket must answer the
        # OPTIONS preflight. Origins are open because the presigned URL
        # itself is the credential and the SDK sends no cookies.
        cat > /tmp/cors.json <<'EOF'
        {
          "CORSRules": [
            {
              "AllowedOrigins": ["*"],
              "AllowedMethods": ["GET", "HEAD", "PUT", "POST", "DELETE"],
              "AllowedHeaders": ["*"],
              "ExposeHeaders": ["ETag", "x-amz-request-id"],
              "MaxAgeSeconds": 3600
            }
          ]
        }
        EOF
        aws --endpoint-url "$$endpoint" s3api put-bucket-cors \
          --bucket "$$bucket" --cors-configuration file:///tmp/cors.json
        echo "bucket CORS configured"
    restart: "no"

  # โ”€โ”€ Optional: local LLM โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
  # Behind the `ai` compose profile โ€” only starts when explicitly opted
  # into. Bring up with:
  #   docker compose --profile ai up -d
  # When enabled, also set in your `puter/config/config.json`:
  #   "providers": { "ollama": { "apiBaseUrl": "http://ollama:11434" } }
  # When NOT enabled, set:
  #   "providers": { "ollama": { "enabled": false } }
  # otherwise Puter spams `ECONNREFUSED 127.0.0.1:11434` on startup.
  ollama:
    profiles: ["ai"]
    # CPU-only out of the box; uncomment the GPU `deploy:` block below
    # if you've got nvidia-docker for much faster inference. Disk + RAM
    # scale with the model โ€” `tinyllama` (1.1B, ~640 MB on disk, ~700
    # MB RAM) is the cheapest sane default. Swap via OLLAMA_DEFAULT_MODEL.
    image: ollama/ollama:latest
    container_name: puter-ollama
    restart: unless-stopped
    volumes:
      - ./puter/data/ollama:/root/.ollama:z
    # Uncomment to expose Ollama directly on the host (`localhost:11434`)
    # for `ollama` CLI / OpenAI-API compatible tools. Internal-only by default.
    # ports:
    #   - "11434:11434"
    healthcheck:
      test:
        ["CMD-SHELL", "ollama list >/dev/null 2>&1 || exit 1"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 15s
    # GPU passthrough (NVIDIA). Requires nvidia-container-toolkit on host.
    # deploy:
    #   resources:
    #     reservations:
    #       devices:
    #         - driver: nvidia
    #           count: all
    #           capabilities: [gpu]

  ollama-init:
    profiles: ["ai"]
    # One-shot โ€” ensures the default model is present. `ollama pull` is
    # idempotent: present-and-up-to-date โ†’ fast no-op; missing โ†’ downloads.
    image: ollama/ollama:latest
    container_name: puter-ollama-init
    depends_on:
      ollama:
        condition: service_healthy
    environment:
      OLLAMA_HOST: http://ollama:11434
      OLLAMA_DEFAULT_MODEL: ${OLLAMA_DEFAULT_MODEL:-tinyllama}
    entrypoint:
      - /bin/sh
      - -c
      - |
        set -e
        echo "[ollama-init] ensuring $${OLLAMA_DEFAULT_MODEL}"
        ollama pull "$${OLLAMA_DEFAULT_MODEL}"
        echo "[ollama-init] done"
    restart: "no"

  puter:
    image: ghcr.io/heyputer/puter:main
    pull_policy: always
    # For local builds, create a docker-compose.override.yml;
    # see doc/self-hosting.md#building-from-source-instead-of-pulling.
    container_name: puter
    restart: unless-stopped
    depends_on:
      valkey:
        condition: service_healthy
      mariadb:
        condition: service_healthy
      dynamo:
        condition: service_started
      s3-init:
        condition: service_completed_successfully
    # Internal-only: Caddy reaches it on the compose network. Uncomment
    # to also expose port 4100 directly on the host (useful for debugging).
    # ports:
    #   - "4100:4100"
    expose:
      - "4100"
    environment:
      PUID: 1000
      PGID: 1000
    volumes:
      # Drop your config.json here โ€” see selfhosted/full-stack.md.
      - ./puter/config:/etc/puter:z
      # Persistent runtime data (anything your config points at /var/puter).
      - ./puter/data/puter:/var/puter:z
    healthcheck:
      test: wget --no-verbose --tries=1 --spider http://localhost:4100/ || exit 1
      interval: 30s
      timeout: 3s
      retries: 3
      start_period: 30s

  # Inbound mail. Behind the `smtp` profile, so it only starts when asked for:
  #   docker compose --profile smtp up -d
  # Accepts SMTP from other mail servers and hands each message to the ingress
  # endpoint, which files it into the recipient's mailbox. Needs `userEmail`
  # configured โ€” see doc/self-hosting.md#optional-receiving-mail-over-smtp.
  puter-smtp:
    profiles: ["smtp"]
    image: ghcr.io/heyputer/puter:main
    pull_policy: always
    container_name: puter-smtp
    restart: unless-stopped
    command: ["node", "-r", "./dist/src/backend/telemetry.js", "./dist/src/backend/smtp/index.js"]
    depends_on:
      puter:
        condition: service_healthy
    ports:
      # The container runs unprivileged and binds 2525; 25 is what an MX record
      # means, and many providers block it.
      - "${SMTP_PORT:-25}:2525"
    environment:
      PUID: 1000
      PGID: 1000
    volumes:
      # The same config.json the backend reads; only `userEmail` applies here.
      - ./puter/config:/etc/puter:z

  caddy:
    image: caddy:2.11-alpine
    container_name: puter-caddy
    restart: unless-stopped
    depends_on:
      puter:
        condition: service_started
    ports:
      - "${HTTP_PORT:-80}:80"
      # Uncomment when you enable TLS in caddy/Caddyfile:
      # - "${HTTPS_PORT:-443}:443"
    volumes:
      - ./caddy/Caddyfile:/etc/caddy/Caddyfile:ro,z
      # TLS certs (wildcard fullchain.pem + privkey.pem). Read-only inside.
      - ./puter/tls:/etc/caddy/tls:ro,z
      # Caddy's own state โ€” the cert store it would use if you swap this
      # config over to ACME. Kept under ./puter/data with everything else
      # so one directory is the whole backup.
      - ./puter/data/caddy:/data:z
    healthcheck:
      # Hits Caddy's local-only admin API rather than proxying through to
      # Puter: this reports whether the proxy itself is up and configured,
      # and keeps working once the `:80` block becomes an HTTPS redirect.
      # 127.0.0.1, not localhost โ€” the admin endpoint is IPv4-only and
      # busybox wget tries ::1 first.
      test: ["CMD-SHELL", "wget -qO- --tries=1 --timeout=2 http://127.0.0.1:2019/config/ >/dev/null || exit 1"]
      interval: 10s
      timeout: 3s
      retries: 5
      start_period: 5s