# 3. 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. ## Requirements - **Docker** with the `compose` plugin. - A **domain** with DNS access — you need a wildcard record (`*.your-domain.com` → server IP). Puter routes by subdomain (`api.`, `site.`, `app.`). - Optional: **TLS certs** (or `certbot` to grab them — see Step 4). ## What's running | Container | Image | Role | | --------------- | ------------------------ | ---------------------------------------------------------- | | `puter-nginx` | `nginx:1.27-alpine` | Reverse proxy on 80 (and 443 if TLS); forwards to Puter | | `puter` | `ghcr.io/heyputer/puter` | The app | | `puter-mariadb` | `mariadb:11` | SQL database — schema applied automatically on first boot | | `puter-valkey` | `valkey/valkey:8-alpine` | Redis-compatible cache + rate-limiter | | `puter-dynamo` | `amazon/dynamodb-local` | KV store — table auto-created on first boot | | `puter-s3` | `rustfs/rustfs` | S3-compatible object storage (MinIO drop-in noted in file) | | `puter-s3-init` | `amazon/aws-cli` | One-shot — creates the bucket on first boot, then exits | State lives under `./puter/data//`. --- ## Step 1 — Create `.env` and `puter/config/config.json` > ⚠️ **Run this whole block in one shell session.** It generates secrets once and writes them into both `.env` (read by docker compose) and `config.json` (read by Puter). The two files **must** agree on the MariaDB password and the S3 secret — if they drift, MariaDB initialises with one password and Puter tries to log in with another, and you get `ER_ACCESS_DENIED_ERROR`. ```bash 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 <` tag instead of waiting on a manifest that doesn't exist. - `database.migrationPaths` — Puter applies the bundled MySQL schema on boot. `mysql_mig_1.sql` (tables) and `mysql_mig_2.sql` (default apps: editor, viewer, pdf, camera, player, recorder, git, dev-center, puter-linux). Idempotent — safe to re-run. - `dynamo.bootstrapTables: true` — Puter creates its KV table on boot. **Only set against a local emulator**, never real AWS. - `dynamo.aws` keys are dummies; DynamoDB-local doesn't validate them but the AWS SDK requires _something_. **Note:** DynamoDB uses `access_key` / `secret_key` (snake_case); S3 below uses `accessKeyId` / `secretAccessKey` (camelCase). Not interchangeable. > If you ever change `MARIADB_PASSWORD` after first boot, `.env` alone won't update MariaDB — its credentials are baked into `./puter/data/mariadb/` on first init. Either rotate the password inside MariaDB by hand or `docker compose down && rm -rf ./puter/data/mariadb` to start fresh. ## Step 2 — Point DNS at the server \[Optional\] In your DNS provider, add **two records**: ``` A puter.local → A puter.sitelocal → A *.puter.sitelocal → A puter.hostlocal → A *.puter.hostlocal → A puter.applocal → A *.puter.applocal → A puter.devlocal → A *.puter.devlocal → ``` The wildcard is required — Puter routes via subdomains. If you only need these to resolve these locally to test you can add this (any any other needed subdomain) to your hosts file ``` 127.0.0.1 puter.local ``` ## Step 3 — TLS (recommended for public installs) \[Optional\] Skip this for a quick local demo. Don't skip it for users typing passwords. **Get a wildcard cert.** Easiest path with Let's Encrypt + DNS-01 (works for wildcards): ```bash sudo certbot certonly --manual --preferred-challenges dns \ -d puter.local -d puter.sitelocal -d "*.puter.sitelocal" -d puter.hostlocal -d "*.puter.hostlocal" -d puter.applocal -d "*.puter.applocal" -d puter.devlocal -d "*.puter.devlocal" ``` Drop the resulting `fullchain.pem` and `privkey.pem` into `./puter/tls/`. **Wire nginx to use them:** 1. Open [nginx/nginx.conf](../nginx/nginx.conf), uncomment the entire `# server { listen 443 ssl … }` block. 2. (Optional) Replace the body of the port-80 block with `return 301 https://$host$request_uri;` to force HTTPS. 3. In [docker-compose.full.yml](../docker-compose.full.yml), uncomment the `443:443` port mapping under the `nginx` service. 4. In `.env`, uncomment `HTTPS_PORT=443`. 5. In `config.json`, switch: ```json { "protocol": "https", "pub_port": 443 } ``` ## Step 4 — Bring it up ```bash docker compose -f docker-compose.full.yml 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 ``` Healthy startup: ``` [config] override from /etc/puter/config.json [mysql] running migrations from /opt/puter/dist/src/backend/clients/database/migrations/mysql: 2 file(s) [mysql] applied mysql_mig_1.sql (...) [mysql] applied mysql_mig_2.sql (9 statements) ``` 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 ``` Change it in Settings after first login. ## 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: ```bash docker compose -f docker-compose.full.yml up -d --build ``` --- ## Re-starting backend ```bash # update docker compose -f docker-compose.full.yml pull docker compose -f docker-compose.full.yml up -d # logs docker compose -f docker-compose.full.yml logs -f puter # stop, keep data docker compose -f docker-compose.full.yml down # stop, NUKE all state (irreversible) docker compose -f docker-compose.full.yml down rm -rf puter/data ``` 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`). **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. **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. **Nothing resolves at `puter.example.com` after DNS changes.** 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. **`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.