diff --git a/README.md b/README.md
index 9e2787b66..535eb5d7a 100644
--- a/README.md
+++ b/README.md
@@ -58,49 +58,15 @@ troubleshooting steps.
-### 🐳 Docker
+### 🚀 Self-Hosting (one-line install)
```bash
-mkdir puter && cd puter && mkdir -p puter/config puter/data && sudo chown -R 1000:1000 puter && docker run --rm -p 4100:4100 -v `pwd`/puter/config:/etc/puter -v `pwd`/puter/data:/var/puter ghcr.io/heyputer/puter
+curl -fsSL https://raw.githubusercontent.com/HeyPuter/puter/main/install.sh | sh
```
-**→** This should launch Puter at
- http://puter.localhost:4100 (or the next available port).
-
+Brings up Puter + every external service it needs (MariaDB, Valkey, DynamoDB-local, RustFS S3, nginx) on a single host. Generates secrets, writes `.env` + `puter/config/config.json`, downloads `docker-compose.yml`, and runs `docker compose up -d`.
-### 🐙 Docker Compose
-
-#### Linux/macOS
-
-```bash
-mkdir -p puter/config puter/data
-sudo chown -R 1000:1000 puter
-wget https://raw.githubusercontent.com/HeyPuter/puter/main/docker-compose.yml
-docker compose up
-```
-**→** This should be available at
- http://puter.localhost:4100 (or the next available port).
-
-
-
-#### Windows
-
-```powershell
-mkdir -p puter
-cd puter
-New-Item -Path "puter\config" -ItemType Directory -Force
-New-Item -Path "puter\data" -ItemType Directory -Force
-Invoke-WebRequest -Uri "https://raw.githubusercontent.com/HeyPuter/puter/main/docker-compose.yml" -OutFile "docker-compose.yml"
-docker compose up
-```
-**→** This should launch Puter at
- http://puter.localhost:4100 (or the next available port).
-
-
-
-### 🚀 Self-Hosting
-
-For detailed guides on self-hosting Puter, including configuration options and best practices, see our [Self-Hosting Documentation](https://github.com/HeyPuter/puter/blob/main/doc/self-hosters/instructions.md).
+For the manual walkthrough, TLS setup, and post-install configuration knobs, see [Self-Hosting Documentation](./doc/selfhosting/README.md).
diff --git a/doc/selfhosting/README.md b/doc/selfhosting/README.md
index 899e64b1d..4e12e1922 100644
--- a/doc/selfhosting/README.md
+++ b/doc/selfhosting/README.md
@@ -1,12 +1,11 @@
# Self-hosting Puter
-Three supported ways to run Puter, in increasing order of effort and capability. Pick one, follow that page, ignore the others.
+Two supported ways to run Puter. Pick one, follow that page.
-| Mode | Best for | External services |
-| ------------------------------------------ | ----------------------------------------------------- | ----------------------------------------------------------- |
-| [**1. Dev (npm start)**](./npm.md) | Hacking on the source / trying it on your laptop | None — everything runs in-process |
-| [**2. Docker (single container)**](./docker.md) | Production single-host; bring your own DB / S3 | None bundled — point at services you already run |
-| [**3. Full self-hosted stack**](./full-stack.md) | Production with a self-managed stack | Bundled: MariaDB, Valkey, DynamoDB-local, RustFS S3, nginx |
+| Mode | Best for | External services |
+| ------------------------------------------------ | ------------------------------------------------ | ---------------------------------------------------------- |
+| [**1. Dev (npm start)**](./npm.md) | Hacking on the source / trying it on your laptop | None — everything runs in-process |
+| [**2. Full self-hosted stack**](./full-stack.md) | Production, single host | Bundled: MariaDB, Valkey, DynamoDB-local, RustFS S3, nginx |
---
@@ -16,10 +15,14 @@ Clone, `npm install`, `npm start`. Backend, GUI, and `puter.js` run from the sou
**Not safe to expose publicly** — uses dev secrets and an in-process key store.
-## 2. Docker (single container) → [docker.md](./docker.md)
+## 2. Full self-hosted stack → [full-stack.md](./full-stack.md)
-One `docker run` against `ghcr.io/heyputer/puter:latest`. Out of the box uses the same in-process defaults as dev mode; drop a `config.json` into the mounted `/etc/puter/` to point at real services (MariaDB, S3, DynamoDB, Redis) one block at a time. Best when you already operate the dependencies you want Puter to use.
+`docker compose up -d` brings up Puter **plus every external service it needs** (MariaDB, Valkey, DynamoDB-local, RustFS S3, nginx) wired together. Closest to production you can run on a single host; supports your own domain and TLS.
-## 3. Full self-hosted stack → [full-stack.md](./full-stack.md)
+The fastest path is the one-shot installer — fetches the compose file, generates secrets, writes `.env` + `config.json`, and brings the stack up:
-`docker compose -f docker-compose.full.yml up -d` brings up Puter **plus every external service it needs** (MariaDB, Valkey, DynamoDB-local, RustFS S3, nginx) wired together. Closest to production you can run on a single host; supports your own domain and TLS. Best when you want a public Puter and don't already run the dependencies.
+```bash
+curl -fsSL https://raw.githubusercontent.com/HeyPuter/puter/main/install.sh | sh
+```
+
+See [full-stack.md](./full-stack.md) for the manual walkthrough, TLS setup, and post-install configuration knobs.
diff --git a/doc/selfhosting/docker.md b/doc/selfhosting/docker.md
deleted file mode 100644
index 6407a517c..000000000
--- a/doc/selfhosting/docker.md
+++ /dev/null
@@ -1,182 +0,0 @@
-# 2. Docker (single container)
-
-One Puter container. You bring your own database, S3, etc. — or run with the bundled in-process defaults for a quick spin. The image is multi-arch (`linux/amd64`, `linux/arm64`).
-
-## Requirements
-
-- **Docker** (any recent version).
-
-## Bare minimum — defaults, single command
-
-```bash
-mkdir -p puter/config puter/data
-
-docker run -d \
- --name puter \
- --restart unless-stopped \
- -p 4100:4100 \
- -v $(pwd)/puter/config:/etc/puter \
- -v $(pwd)/puter/data:/var/puter \
- ghcr.io/heyputer/puter:latest
-```
-
-Open . With nothing in `puter/config/`, the in-process defaults kick in (same SQLite + dynalite + fauxqs + redis-mock as dev mode). State lands in `puter/data/`. Login is `admin` — temp password is printed once in `docker logs puter`.
-
-That's enough to confirm the image works. Now configure for real.
-
-## Add a config
-
-The container reads **`/etc/puter/config.json`** and deep-merges it on top of the bundled defaults. You only put the keys you want to change.
-
-```bash
-cat > puter/config/config.json <<'JSON'
-{
- "domain": "puter.example.com",
- "protocol": "https",
- "pub_port": 443,
- "env": "prod",
-
- "jwt_secret": "REPLACE-WITH-openssl-rand-hex-64",
- "url_signature_secret": "REPLACE-WITH-A-DIFFERENT-openssl-rand-hex-64"
-}
-JSON
-
-docker restart puter
-```
-
-> 🔒 **Always replace `jwt_secret` and `url_signature_secret`.** The defaults are baked into the public image. Generate with `openssl rand -hex 64`.
-
-Watch the logs:
-
-```bash
-docker logs -f puter
-```
-
-Look for `[config] override from /etc/puter/config.json` — that's the success signal.
-
-## Wire to external services
-
-Drop the relevant block(s) into `config.json`. Mix and match. Restart with `docker restart puter` after any change.
-
-### MySQL / MariaDB
-
-Puter applies its schema on first boot when you set `migrationPaths`:
-
-```json
-{
- "database": {
- "engine": "mysql",
- "host": "db.internal", "port": 3306,
- "user": "puter", "password": "...", "database": "puter",
- "migrationPaths": ["/opt/puter/dist/src/backend/clients/database/migrations/mysql"]
- }
-}
-```
-
-Two files run in order: `mysql_mig_1.sql` (tables) and `mysql_mig_2.sql` (default apps — editor, viewer, pdf, camera, player, recorder, git, dev-center, puter-linux). Both are idempotent — safe to re-run.
-
-### S3 (real or S3-compatible)
-
-```json
-{
- "s3": {
- "s3Config": {
- "endpoint": "https://s3.example.com",
- "accessKeyId": "...", "secretAccessKey": "...",
- "region": "us-east-1"
- }
- },
- "s3_bucket": "my-puter-bucket",
- "s3_region": "us-east-1"
-}
-```
-
-The bucket must exist already — Puter doesn't create it.
-
-For real AWS S3 the example above works as-is — virtual-hosted DNS form (`.s3.amazonaws.com`) is the AWS SDK default. For S3-compatible servers (RustFS, MinIO, fauxqs), add `"forcePathStyle": true` inside the `s3Config` block — virtual-hosted DNS doesn't resolve there.
-
-> ⚠️ **S3 uses camelCase keys** (`accessKeyId` / `secretAccessKey`). DynamoDB below uses snake_case. They're not the same.
-
-### DynamoDB (real AWS)
-
-Provision the table externally (e.g. Terraform):
-
-```json
-{
- "dynamo": {
- "aws": { "access_key": "...", "secret_key": "...", "region": "us-east-1" }
- }
-}
-```
-
-The KV table is named `store-kv-v1`. Schema: hash `namespace` (S), range `key` (S), LSI `lsi1-index` on `lsi1` (S), TTL on `ttl`.
-
-### Redis / Valkey cluster
-
-Puter speaks ioredis cluster protocol. Real Redis cluster:
-
-```json
-{ "redis": { "startupNodes": [{ "host": "redis-0", "port": 6379 }] } }
-```
-
-For a self-hosted single Valkey/Redis container, run it in cluster mode (one node, all 16384 slots assigned to itself) and turn off TLS:
-
-```json
-{
- "redis": {
- "startupNodes": [{ "host": "valkey", "port": 6379 }],
- "tls": false
- }
-}
-```
-
-TLS defaults to on (matches the prod ElastiCache shape) — set `false` for plain-TCP self-host.
-
-## What persists?
-
-Anything your config points at `/var/puter/...` lives on the host via the `puter/data` mount (SQLite path, fauxqs data dirs if you use them, etc.). If you've moved every dependency to external services, the data volume is mostly empty and optional.
-
-## Updating
-
-```bash
-docker pull ghcr.io/heyputer/puter:latest
-docker rm -f puter
-# re-run the docker run command above
-```
-
-Your `config.json` and persistent data are untouched.
-
-## Building the image yourself
-
-```bash
-docker build -t puter .
-
-# Multi-arch (requires buildx, on by default in modern Docker):
-docker buildx build \
- --platform linux/amd64,linux/arm64 \
- -t your-registry/puter:latest \
- --push .
-```
-
-A `docker-compose.yml` in this directory has a commented-out `build:` block — uncomment it (and flip `pull_policy` to `never`) to build from your local checkout instead of pulling.
-
-## Troubleshooting
-
-**`docker logs puter` shows the container restarting.**
-Almost always JSON syntax in `config.json`. Validate: `jq . puter/config/config.json`.
-
-**The config file isn't picked up.**
-Confirm it resolves to `/etc/puter/config.json` *inside* the container:
-```bash
-docker exec puter cat /etc/puter/config.json
-```
-Empty / missing → the volume mount path is wrong.
-
-**Healthcheck reports unhealthy but the site works.**
-The healthcheck hits `puter.localhost:4100/test` from inside the container. If you changed `domain` or `port`, the check still uses defaults. The site itself is fine.
-
-**`Error: DynamoDB aws config requires both access_key and secret_key`.**
-You wrote `accessKeyId` / `secretAccessKey` (the AWS SDK form) under `dynamo.aws`. DynamoDB config uses snake_case. See above.
-
-**Architecture mismatch on Apple Silicon / ARM hosts.**
-Use the published `:latest` tag — it's already multi-arch. If you built locally with `docker build` on an Intel Mac, the resulting image will be `linux/amd64` only.
diff --git a/doc/selfhosting/full-stack.md b/doc/selfhosting/full-stack.md
index edc3c9b30..e9bfeb46b 100644
--- a/doc/selfhosting/full-stack.md
+++ b/doc/selfhosting/full-stack.md
@@ -1,6 +1,14 @@
-# 3. Full self-hosted stack
+# Full self-hosted stack
-`docker-compose.full.yml` brings up Puter **plus every external service it needs** — MariaDB, Valkey, DynamoDB-local, RustFS S3, nginx — wired together. Closest thing to a production deployment you can self-manage on a single host.
+`docker-compose.yml` brings up Puter **plus every external service it needs** — MariaDB, Valkey, DynamoDB-local, RustFS S3, nginx — wired together. Closest thing to a production deployment you can self-manage on a single host.
+
+## One-line installer (recommended)
+
+```bash
+curl -fsSL https://raw.githubusercontent.com/HeyPuter/puter/main/install.sh | sh
+```
+
+Generates secrets, writes `.env` + `puter/config/config.json`, downloads `docker-compose.yml` from the OSS repo, and runs `docker compose up -d`. Re-running is safe — it won't overwrite existing config (set `PUTER_FORCE=1` to rotate). Use the manual steps below if you want to inspect or tweak each step yourself.
## Requirements
@@ -176,7 +184,7 @@ Drop the resulting `fullchain.pem` and `privkey.pem` into `./puter/tls/`.
1. Open [nginx/nginx.conf](../nginx/nginx.conf), uncomment **both** `# server { listen 443 ssl … }` blocks (one for `s3.*`, one for the catch-all).
2. (Optional) Replace the body of the port-80 blocks with `return 301 https://$host$request_uri;` to force HTTPS everywhere.
-3. In [docker-compose.full.yml](../docker-compose.full.yml), uncomment the `443:443` port mapping under the `nginx` service.
+3. In [docker-compose.yml](../docker-compose.yml), uncomment the `443:443` port mapping under the `nginx` service.
4. In `.env`, uncomment `HTTPS_PORT=443`.
5. In `config.json`, switch:
```json
@@ -190,13 +198,13 @@ Drop the resulting `fullchain.pem` and `privkey.pem` into `./puter/tls/`.
## Step 4 — Bring it up
```bash
-docker compose -f docker-compose.full.yml up -d
+docker compose up -d
```
First boot takes ~30s while MariaDB initialises and Puter applies the schema + default apps. Watch:
```bash
-docker compose -f docker-compose.full.yml logs -f puter
+docker compose logs -f puter
```
Healthy startup:
@@ -211,14 +219,14 @@ Healthy startup:
Then open **** (or `http://` if you skipped TLS). Login is `admin` — the temp password is printed once in the puter container logs on first boot:
```bash
-docker compose -f docker-compose.full.yml logs puter | grep tmp_password
+docker compose logs puter | grep tmp_password
```
Change it in Settings after first login.
## Additional configuration
-All optional. Drop any of the blocks below into `puter/config/config.json` and `docker compose -f docker-compose.full.yml restart puter`. See [config.template.jsonc](../config.template.jsonc) for the full list. Per-key documentation lives in [src/backend/types.ts](../src/backend/types.ts).
+All optional. Drop any of the blocks below into `puter/config/config.json` and `docker compose restart puter`. See [config.template.jsonc](../config.template.jsonc) for the full list. Per-key documentation lives in [src/backend/types.ts](../src/backend/types.ts).
### Email (SMTP)
@@ -328,21 +336,21 @@ The `ollama` and `ollama-init` services live behind a compose profile so they do
```
3. Bring up with the `ai` profile:
```bash
- docker compose -f docker-compose.full.yml --profile ai up -d
- docker compose -f docker-compose.full.yml logs -f ollama-init
+ docker compose --profile ai up -d
+ docker compose logs -f ollama-init
```
`ollama-init` exits 0 once the model is pulled. Subsequent boots find the model already on disk and the pull is a fast no-op.
Without `--profile ai`, the `ollama` containers stay down and Puter (with `enabled: false`) doesn't try to reach them — the rest of the stack runs identically.
-For GPU acceleration (NVIDIA), uncomment the `deploy:` block under the `ollama` service in [docker-compose.full.yml](../docker-compose.full.yml). Requires `nvidia-container-toolkit` on the host.
+For GPU acceleration (NVIDIA), uncomment the `deploy:` block under the `ollama` service in [docker-compose.yml](../docker-compose.yml). Requires `nvidia-container-toolkit` on the host.
## Building from source instead of pulling
-If you want to test local Dockerfile changes against the full stack, uncomment the `build:` block in [docker-compose.full.yml](../docker-compose.full.yml) under the `puter` service, change `pull_policy: always` → `pull_policy: never`, then:
+If you want to test local Dockerfile changes against the full stack, uncomment the `build:` block in [docker-compose.yml](../docker-compose.yml) under the `puter` service, change `pull_policy: always` → `pull_policy: never`, then:
```bash
-docker compose -f docker-compose.full.yml up -d --build
+docker compose up -d --build
```
---
@@ -351,17 +359,17 @@ docker compose -f docker-compose.full.yml up -d --build
```bash
# update
-docker compose -f docker-compose.full.yml pull
-docker compose -f docker-compose.full.yml up -d
+docker compose pull
+docker compose up -d
# logs
-docker compose -f docker-compose.full.yml logs -f puter
+docker compose logs -f puter
# stop, keep data
-docker compose -f docker-compose.full.yml down
+docker compose down
# stop, NUKE all state (irreversible)
-docker compose -f docker-compose.full.yml down
+docker compose down
rm -rf puter/data
```
@@ -370,10 +378,10 @@ Migrations re-apply idempotently across pulls. Volumes are preserved.
## Troubleshooting
**Site loads but I get "Bad Gateway" / nginx errors.**
-The puter container failed to come up. `docker compose -f docker-compose.full.yml logs puter` will tell you which dependency rejected it (most often DB password mismatch between `.env` and `config.json`).
+The puter container failed to come up. `docker compose logs puter` will tell you which dependency rejected it (most often DB password mismatch between `.env` and `config.json`).
**Login screen says "admin password not set".**
-First-boot temp password is logged once. Find it: `docker compose -f docker-compose.full.yml logs puter | grep "tmp_password"`. After login, change it in Settings.
+First-boot temp password is logged once. Find it: `docker compose logs puter | grep "tmp_password"`. After login, change it in Settings.
**Healthcheck reports unhealthy but the site works.**
The healthcheck hits `puter.localhost:4100/test` from inside the container. If you changed `domain` or `port`, the check still uses defaults. The site itself is fine.
@@ -382,7 +390,7 @@ The healthcheck hits `puter.localhost:4100/test` from inside the container. If y
DNS propagates slowly. `dig puter.example.com` and `dig api.puter.example.com` should both return your server IP. If not, give it 5–60 minutes.
**`docker compose up` hangs at "waiting for service to be healthy".**
-`docker compose -f docker-compose.full.yml ps` shows which container is unhealthy. MariaDB takes ~20–30s on a cold boot; everything else under 5s. If something stays unhealthy, `logs ` will tell you why.
+`docker compose ps` shows which container is unhealthy. MariaDB takes ~20–30s on a cold boot; everything else under 5s. If something stays unhealthy, `logs ` will tell you why.
**`Error: DynamoDB aws config requires both access_key and secret_key`.**
You wrote `accessKeyId` / `secretAccessKey` under `dynamo.aws`. That config block uses snake_case (`access_key` / `secret_key`). Only the `s3.s3Config` block uses camelCase.
diff --git a/docker-compose.full.yml b/docker-compose.full.yml
deleted file mode 100644
index 9134aa98e..000000000
--- a/docker-compose.full.yml
+++ /dev/null
@@ -1,285 +0,0 @@
----
-# Self-hosted Puter — full stack.
-#
-# Brings up Puter + every external service it needs:
-# - nginx : 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 -f docker-compose.full.yml up -d
-#
-# Production:
-# - Always replace the default passwords / S3 keys / Puter secrets.
-# - Front Puter with TLS-terminating reverse proxy (Caddy / nginx).
-# - 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:
- - ./puter/data/valkey:/data
- 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
- 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
-
- 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
- # Internal-only — browsers reach RustFS via nginx (`s3.`),
- # 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 on first
- # boot. Exits 0 once the bucket exists; stays exited 0 thereafter.
- 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
- restart: "no"
-
- # ── Optional: local LLM ───────────────────────────────────────────
- # Behind the `ai` compose profile — only starts when explicitly opted
- # into. Bring up with:
- # docker compose -f docker-compose.full.yml --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
- # 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:latest
- pull_policy: always
- # Uncomment to build from this directory instead of pulling the published
- # image. Also flip pull_policy to `never` so compose doesn't overwrite
- # your local build by re-pulling :latest.
- build:
- context: .
- # buildx-only: cross-compile to both archs in a single push
- platforms:
- # - linux/amd64
- - linux/arm64
- 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: nginx 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
- # Persistent runtime data (anything your config points at /var/puter).
- - ./puter/data/puter:/var/puter
- healthcheck:
- test: wget --no-verbose --tries=1 --spider http://puter.localhost:4100/test || exit 1
- interval: 30s
- timeout: 3s
- retries: 3
- start_period: 30s
-
- nginx:
- image: nginx:1.27-alpine
- container_name: puter-nginx
- restart: unless-stopped
- depends_on:
- puter:
- condition: service_started
- ports:
- - "${HTTP_PORT:-80}:80"
- # Uncomment when you enable TLS in nginx/nginx.conf:
- # - "${HTTPS_PORT:-443}:443"
- volumes:
- - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- # TLS certs (fullchain.pem + privkey.pem). Read-only inside.
- - ./puter/tls:/etc/nginx/tls:ro
- healthcheck:
- test: ["CMD-SHELL", "wget -qO- --tries=1 --timeout=2 http://localhost/ || exit 1"]
- interval: 10s
- timeout: 3s
- retries: 5
- start_period: 5s
diff --git a/docker-compose.yml b/docker-compose.yml
index e5e1cb7bc..402d6f8f5 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,34 +1,290 @@
---
+# Self-hosted Puter — full stack.
+#
+# Brings up Puter + every external service it needs:
+# - nginx : 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.
+# - Front Puter with TLS-terminating reverse proxy (Caddy / nginx).
+# - 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:
+ - ./puter/data/valkey:/data
+ 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
+ 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
+
+ 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
+ # Internal-only — browsers reach RustFS via nginx (`s3.`),
+ # 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 on first
+ # boot. Exits 0 once the bucket exists; stays exited 0 thereafter.
+ 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
+ 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
+ # 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:
- container_name: puter
- image: ghcr.io/heyputer/puter:latest
+ image: ghcr.io/heyputer/puter:main
pull_policy: always
- # Uncomment to build from this directory instead of pulling the published image:
+ # Uncomment to build from this directory instead of pulling the published
+ # image. Also flip pull_policy to `never` so compose doesn't overwrite
+ # your local build by re-pulling :latest.
# build:
# context: .
# # buildx-only: cross-compile to both archs in a single push
# # platforms:
# # - linux/amd64
# # - linux/arm64
+ container_name: puter
restart: unless-stopped
- ports:
- - '4100:4100'
+ depends_on:
+ valkey:
+ condition: service_healthy
+ mariadb:
+ condition: service_healthy
+ dynamo:
+ condition: service_started
+ s3-init:
+ condition: service_completed_successfully
+ # Internal-only: nginx 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:
- # TZ: Europe/Paris
PUID: 1000
PGID: 1000
volumes:
- # Drop your config.json into ./puter/config/. It is deep-merged over
- # config.default.json — only override what you care to change.
- # Image expects /etc/puter/config.json (see PUTER_CONFIG_PATH in Dockerfile).
+ # Drop your config.json here — see selfhosted/full-stack.md.
- ./puter/config:/etc/puter
- # Persistent runtime data (sqlite db, uploads, etc. — depends on your
- # config). Maps to volatile/ inside the container by default.
- - ./puter/data:/var/puter
+ # Persistent runtime data (anything your config points at /var/puter).
+ - ./puter/data/puter:/var/puter
healthcheck:
test: wget --no-verbose --tries=1 --spider http://puter.localhost:4100/test || exit 1
interval: 30s
timeout: 3s
retries: 3
start_period: 30s
+
+ nginx:
+ image: nginx:1.27-alpine
+ container_name: puter-nginx
+ restart: unless-stopped
+ depends_on:
+ puter:
+ condition: service_started
+ ports:
+ - "${HTTP_PORT:-80}:80"
+ # Uncomment when you enable TLS in nginx/nginx.conf:
+ # - "${HTTPS_PORT:-443}:443"
+ volumes:
+ - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
+ # TLS certs (fullchain.pem + privkey.pem). Read-only inside.
+ - ./puter/tls:/etc/nginx/tls:ro
+ healthcheck:
+ test: ["CMD-SHELL", "wget -qO- --tries=1 --timeout=2 http://localhost/ || exit 1"]
+ interval: 10s
+ timeout: 3s
+ retries: 5
+ start_period: 5s
diff --git a/install.sh b/install.sh
new file mode 100755
index 000000000..7d514e2bb
--- /dev/null
+++ b/install.sh
@@ -0,0 +1,163 @@
+#!/usr/bin/env sh
+# Self-hosted Puter — one-shot installer.
+#
+# Usage:
+# curl -fsSL https://raw.githubusercontent.com/HeyPuter/puter/main/install.sh | sh
+#
+# What this does, in order:
+# 1. Checks that docker (with the compose plugin), curl, and openssl exist.
+# 2. Creates ./puter-selfhosted/ (override with PUTER_DIR=...).
+# 3. Downloads docker-compose.yml from the OSS repo (raw.githubusercontent.com).
+# 4. Generates fresh secrets and writes .env + puter/config/config.json.
+# 5. Runs `docker compose up -d` and prints the first-boot admin password.
+#
+# Re-running the script in an already-initialised directory is a no-op for
+# config (it won't clobber existing .env / config.json) and just refreshes
+# the compose file + brings the stack up. Set PUTER_FORCE=1 to overwrite.
+#
+# Tunable env vars:
+# PUTER_DIR install directory (default: ./puter-selfhosted)
+# PUTER_URL base URL to fetch docker-compose.yml (default: GitHub raw, main branch)
+# PUTER_DOMAIN domain Puter will serve on (default: puter.localhost)
+# PUTER_PORT HTTP port for nginx (default: 80)
+# PUTER_FORCE set to 1 to overwrite existing .env / config.json
+
+set -eu
+
+PUTER_DIR="${PUTER_DIR:-puter-selfhosted}"
+PUTER_URL="${PUTER_URL:-https://raw.githubusercontent.com/HeyPuter/puter/main}"
+PUTER_DOMAIN="${PUTER_DOMAIN:-puter.localhost}"
+PUTER_PORT="${PUTER_PORT:-80}"
+PUTER_FORCE="${PUTER_FORCE:-0}"
+
+log() { printf '\033[1;36m[puter-install]\033[0m %s\n' "$*"; }
+warn() { printf '\033[1;33m[puter-install]\033[0m %s\n' "$*" >&2; }
+die() { printf '\033[1;31m[puter-install]\033[0m %s\n' "$*" >&2; exit 1; }
+
+need() {
+ command -v "$1" >/dev/null 2>&1 || die "missing required command: $1"
+}
+
+# ── Step 1: dependency check ────────────────────────────────────────
+log "checking dependencies"
+need docker
+need curl
+need openssl
+docker compose version >/dev/null 2>&1 \
+ || die "docker compose plugin not found — install docker desktop or 'docker-compose-plugin'"
+
+# ── Step 2: install dir ─────────────────────────────────────────────
+mkdir -p "$PUTER_DIR"
+cd "$PUTER_DIR"
+mkdir -p puter/config puter/data puter/tls
+log "install dir: $(pwd)"
+
+# ── Step 3: docker-compose.yml ──────────────────────────────────────
+log "downloading docker-compose.yml from $PUTER_URL"
+curl -fsSL "$PUTER_URL/docker-compose.yml" -o docker-compose.yml \
+ || die "could not fetch $PUTER_URL/docker-compose.yml"
+
+# ── Step 4: secrets, .env, config.json ──────────────────────────────
+write_config=1
+if [ -f .env ] && [ -f puter/config/config.json ] && [ "$PUTER_FORCE" != "1" ]; then
+ log ".env + config.json already present — keeping existing secrets (PUTER_FORCE=1 to overwrite)"
+ write_config=0
+fi
+
+if [ "$write_config" = "1" ]; then
+ log "generating secrets"
+ MARIADB_ROOT_PASSWORD=$(openssl rand -hex 32)
+ MARIADB_PASSWORD=$(openssl rand -hex 32)
+ S3_SECRET_KEY=$(openssl rand -hex 32)
+ JWT_SECRET=$(openssl rand -hex 64)
+ URL_SIGNATURE_SECRET=$(openssl rand -hex 64)
+
+ cat > .env < puter/config/config.json <