Portainer and Dockhand
Deploy the app and its services using a stack.
Web editor
There's no custom database image; deploy against
stock postgres. The app builds and migrates its own schema on boot (via
MIGRATE_DATABASE_URL). Database setup requires the least-privilege
tabletop_app role and an explicit bootstrap administrator. Since the web
editor can't mount local files, the stack below injects both the role setup and bootstrap
password as inline config files.
Before deploying, add these values to the stack's environment-variable editor. In Dockhand, mark the three password values as secrets so their saved values remain masked. These are stack variables used while Compose renders the YAML; the example explicitly passes only the values the running containers need.
| Variable | Required | Purpose |
|---|---|---|
DB_PASSWORD |
Yes | Password for the database owner/migration role. |
APP_DB_PASSWORD |
Yes | Password for the least-privilege runtime role. |
BOOTSTRAP_ADMIN_USERNAME |
Yes on first deployment |
Initial admin username; 3-20 letters, numbers, _, or -.
|
BOOTSTRAP_ADMIN_EMAIL |
Yes on first deployment | Initial admin sign-in email. |
BOOTSTRAP_ADMIN_PASSWORD |
Yes on first deployment | Initial admin password; 12-1024 characters. |
SESSION_TTL_DAYS |
No | Login lifetime in days; defaults to 30. |
TRUST_PROXY_HOPS |
No | Exact reverse-proxy hop count; defaults to 0. |
AUTO_MIGRATE |
No | Apply pending migrations at startup; defaults to true. |
# Open Tabletop ; app + stock Postgres (Portainer/Dockhand stack)
configs:
app_role_init: # runs once on first DB init ; creates the app role
content: |
CREATE ROLE tabletop_app LOGIN PASSWORD '${APP_DB_PASSWORD}';
GRANT CONNECT ON DATABASE tabletop TO tabletop_app;
GRANT USAGE ON SCHEMA public TO tabletop_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO tabletop_app;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO tabletop_app;
ALTER DEFAULT PRIVILEGES FOR ROLE tabletop IN SCHEMA public
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO tabletop_app;
ALTER DEFAULT PRIVILEGES FOR ROLE tabletop IN SCHEMA public
GRANT USAGE, SELECT ON SEQUENCES TO tabletop_app;
bootstrap_admin_password: # mounted read-only; read during first-user bootstrap
content: |
${BOOTSTRAP_ADMIN_PASSWORD:?set BOOTSTRAP_ADMIN_PASSWORD in the stack environment}
services:
db:
image: postgres:16-alpine
restart: unless-stopped
container_name: open-tabletop-db
environment:
POSTGRES_USER: tabletop # owner role ; the app migrates the schema as this
POSTGRES_DB: tabletop
POSTGRES_PASSWORD: ${DB_PASSWORD}
configs:
- source: app_role_init
target: /docker-entrypoint-initdb.d/02-app-role.sql
volumes:
- db-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U tabletop -d tabletop"]
interval: 5s
timeout: 3s
retries: 12
redis:
image: redis:7-alpine
restart: unless-stopped
command: ["redis-server", "--save", "", "--appendonly", "no"]
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 12
app:
image: optimuspryne/open-tabletop:0.20.0
user: appuser # matches the image's non-root runtime user
restart: unless-stopped
container_name: open-tabletop-app
depends_on:
db:
condition: service_healthy # wait until the role is in place
redis:
condition: service_healthy
environment:
NODE_ENV: production
DATABASE_URL: postgresql://tabletop_app:${APP_DB_PASSWORD}@db:5432/tabletop
# Owner (DDL) role ; the app builds & migrates the schema on boot (migrate.js).
MIGRATE_DATABASE_URL: postgresql://tabletop:${DB_PASSWORD}@db:5432/tabletop
AUTO_MIGRATE: "${AUTO_MIGRATE:-true}"
REDIS_URL: redis://redis:6379
RATE_LIMIT_STORE: redis
BOOTSTRAP_ADMIN_USERNAME: "${BOOTSTRAP_ADMIN_USERNAME:?set BOOTSTRAP_ADMIN_USERNAME in the stack environment}"
BOOTSTRAP_ADMIN_EMAIL: "${BOOTSTRAP_ADMIN_EMAIL:?set BOOTSTRAP_ADMIN_EMAIL in the stack environment}"
BOOTSTRAP_ADMIN_PASSWORD_FILE: /run/secrets/bootstrap_admin_password
ASSETS_DIR: /data/assets
SESSION_TTL_DAYS: "${SESSION_TTL_DAYS:-30}"
# Set this to the exact number of reverse proxies in front of the app.
TRUST_PROXY_HOPS: "${TRUST_PROXY_HOPS:-0}"
PORT: "2567"
ports:
- "2567:2567"
volumes:
- assets:/data/assets # uploaded decks/boards/props/skyboxes
configs:
- source: bootstrap_admin_password
target: /run/secrets/bootstrap_admin_password
volumes:
db-data:
assets:
Open http://localhost:2567 and sign in with the bootstrap administrator configured in the stack environment. Bootstrap provisioning runs only when the users table is empty; later stack deployments and container restarts never reset that account or its password.
You do not create bootstrap_admin_password on the Docker host. The top-level
Compose config renders BOOTSTRAP_ADMIN_PASSWORD into a read-only
file, mounts it inside the app container at
/run/secrets/bootstrap_admin_password, and sets
BOOTSTRAP_ADMIN_PASSWORD_FILE to that path. The app reads the file, removes
trailing line endings, validates the password length, and hashes the password before
storing it. The /run/secrets path is only the mount location here; this
particular value is supplied by an inline Compose config.
After changing a stack variable, use Deploy, Update, or Recreate in the stack manager so Compose regenerates the config and container. A plain container restart keeps the previously rendered config. When editing a masked Dockhand variable, retain the saved secret or enter the real value again, do not replace it with the displayed mask.
The startup message .env not found. Continuing without it. is expected in
this deployment: the stack manager supplies the environment instead. If startup reports
that the bootstrap password is too short, check the mounted value's length without
displaying the secret:
docker exec open-tabletop-app node -e "const fs=require('fs'); const p=fs.readFileSync(process.env.BOOTSTRAP_ADMIN_PASSWORD_FILE,'utf8').replace(/[\\r\\n]+$/,''); console.log(p.length)"
The result must be at least 12. A result of 0 means the stack
variable was empty; a result such as 3 usually means a UI mask like
*** was saved literally. Correct the variable in the stack manager and
redeploy/recreate the stack.
On first boot the db creates tabletop_app from the inline config, and the app
builds the full schema via MIGRATE_DATABASE_URL (adopting an existing schema
if you're pointing at an old volume), then provisions the bootstrap administrator. The
assets volume holds uploaded files; library
metadata lives in Postgres.
The official image pins appuser to UID 100 and
appgroup to GID 101. To store uploads on NFS, provision the
exported assets directory with that numeric ownership and writable directory permissions
on the NFS server (for example, chown 100:101 and chmod 2770).
Then replace the empty assets: definition at the bottom of the stack with an
NFS-backed named volume:
assets:
driver: local
driver_opts:
type: nfs
o: "addr=${NFS_SERVER},rw,nfsvers=4"
device: ":${NFS_ASSETS_PATH}"
Set NFS_SERVER to the server address and NFS_ASSETS_PATH to its
exported path. Ownership must be prepared server-side, especially when the export uses
root squashing; the non-root app container cannot repair NFS ownership itself.
Older Portainer, Dockhand, or Compose without inline-config
support?
Drop the top-level configs: block, both service-level
configs: entries, and the three BOOTSTRAP_ADMIN_* entries. Bring
the stack up, then create the role once by hand:
docker exec -i open-tabletop-db psql -U tabletop -d tabletop <<'SQL'
CREATE ROLE tabletop_app LOGIN PASSWORD 'your-APP_DB_PASSWORD';
GRANT CONNECT ON DATABASE tabletop TO tabletop_app;
GRANT USAGE ON SCHEMA public TO tabletop_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO tabletop_app;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO tabletop_app;
ALTER DEFAULT PRIVILEGES FOR ROLE tabletop IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO tabletop_app;
ALTER DEFAULT PRIVILEGES FOR ROLE tabletop IN SCHEMA public GRANT USAGE, SELECT ON SEQUENCES TO tabletop_app;
SQL
docker restart open-tabletop-app
After the app starts, create an account and promote it explicitly:
docker exec open-tabletop-app npm run admin:grant -- your@email.example
Password characters and optional settings
The stack example inserts the database passwords into both SQL and connection URLs. Use a
separate generated hexadecimal password for each database role, for example the output of
openssl rand -hex 24. This avoids quote, dollar-sign, and URL-encoding
problems in this particular example. Keep existing database passwords on upgrades.
The stack does not pass SMTP settings to the app as written. To enable recovery email in a
source version that includes it, add PUBLIC_ORIGIN, SMTP_HOST,
SMTP_PORT, SMTP_FROM, and any authentication settings to
services.app.environment, and mount the password file at the path
specified by SMTP_PASSWORD_FILE. Merely adding stack variables does not
inject them into the container. Follow Email recovery and deploy
the updated stack. The pinned 0.20.0 image predates account recovery.
Sources checked: README.md, Dockerfile, server/database-config.js. Reviewed September 29, 2026 against the working source. Published releases may differ; check the changelog for your version.