Files
puter/selfhosted/full-stack.md
T
Daniel SalazarandGitHub 2de8807942 selfhost: fullstack self host p1 (#2902)
* wip: self hosted full setup

* wip: self hoast modes

* more wip self host stuff

* wip: full release

* fix: custom apps

* fix: readme

* full-stack md

* docs update

* fix

* fix: commands

* remove comments

* fix: docs and migs

* mysql mig

* fix: docker changes

* fix: mysql checks

* fix: mysql mig

* fix: docker more

* fix: redis conn issues
2026-05-04 15:58:03 -07:00

10 KiB
Raw Blame History

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.<domain>, site.<domain>, app.<domain>).
  • 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/<service>/.


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.

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 <<EOF
HTTP_PORT=80
# HTTPS_PORT=443     # uncomment after enabling TLS in Step 3

MARIADB_ROOT_PASSWORD=$MARIADB_ROOT_PASSWORD
MARIADB_DATABASE=puter
MARIADB_USER=puter
MARIADB_PASSWORD=$MARIADB_PASSWORD

S3_ACCESS_KEY=puter
S3_SECRET_KEY=$S3_SECRET_KEY
S3_BUCKET=puter-local
EOF

mkdir -p puter/config puter/data puter/tls
cat > puter/config/config.json <<EOF
{
    "domain": "puter.local",
    "protocol": "http",
    "pub_port": 80,
    "env": "prod",

    "static_hosting_domain": "puter.sitelocal",
    "static_hosting_domain_alt": "puter.hostlocal",
    "private_app_hosting_domain": "puter.applocal",
    "private_app_hosting_domain_alt": "puter.devlocal",

    "jwt_secret": "$JWT_SECRET",
    "url_signature_secret": "$URL_SIGNATURE_SECRET",

    "database": {
        "engine": "mysql",
        "host": "mariadb",
        "port": 3306,
        "user": "puter",
        "password": "$MARIADB_PASSWORD",
        "database": "puter",
        "migrationPaths": ["/opt/puter/dist/src/backend/clients/database/migrations/mysql"]
    },

    "redis": {
        "startupNodes": [{ "host": "valkey", "port": 6379 }],
        "tls": false
    },

    "dynamo": {
        "endpoint": "http://dynamo:8000",
        "bootstrapTables": true,
        "aws": {
            "access_key": "fake",
            "secret_key": "fake",
            "region": "us-east-1"
        }
    },

    "s3": {
        "s3Config": {
            "endpoint": "http://s3:9000",
            "accessKeyId": "puter",
            "secretAccessKey": "$S3_SECRET_KEY",
            "region": "us-east-1"
        }
    },
    "s3_bucket": "puter-local",
    "s3_region": "us-east-1"
}
EOF

Replace puter.local, puter.sitelocal, puter.hostlocal, puter.applocal and puter.devlocal with your actual domain (or leave it for a localhost-only trial).

Why these knobs:

  • env: "prod" — the bundled config.default.json ships with env: "dev" (matches the source-tree npm run start=gui workflow, which expects webpack-dev-server emitting a CSS manifest). Self-host runs against pre-built static bundles, so env: "prod" makes the homepage emit the /dist/bundle.min.css <link> 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         → <your server's public IP>
A      puter.sitelocal     → <your server's public IP>
A      *.puter.sitelocal   → <your server's public IP>
A      puter.hostlocal     → <your server's public IP>
A      *.puter.hostlocal   → <your server's public IP>
A      puter.applocal      → <your server's public IP>
A      *.puter.applocal    → <your server's public IP>
A      puter.devlocal      → <your server's public IP>
A      *.puter.devlocal    → <your server's public IP>

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

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

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, 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, uncomment the 443:443 port mapping under the nginx service.
  4. In .env, uncomment HTTPS_PORT=443.
  5. In config.json, switch:
    { "protocol": "https", "pub_port": 443 }
    

Step 4 — Bring it up

docker compose -f docker-compose.full.yml up -d

First boot takes ~30s while MariaDB initialises and Puter applies the schema + default apps. Watch:

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 https://puter.local (or http:// if you skipped TLS). Login is admin — the temp password is printed once in the puter container logs on first boot:

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 under the puter service, change pull_policy: alwayspull_policy: never, then:

docker compose -f docker-compose.full.yml up -d --build

Re-starting backend

# 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 560 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 ~2030s on a cold boot; everything else under 5s. If something stays unhealthy, logs <service> 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.