Layers — flexible all-in-one project & knowledge management platform. Instant launch via Docker.
10K+
Self-hosted collaborative workspace for teams.
Create a new folder and save this as docker-compose.yml:
name: layers
services:
db:
image: postgres:15
environment:
POSTGRES_DB: layersdb
POSTGRES_USER: layers
POSTGRES_PASSWORD: changeme
healthcheck:
test: ["CMD-SHELL", "pg_isready -U layers -d layersdb"]
interval: 2s
timeout: 2s
retries: 30
volumes:
- pg_data:/var/lib/postgresql/data
layers:
image: hissih/layers:latest
ports:
- "8080:8080"
healthcheck:
test: ["CMD-SHELL", "curl -sf http://localhost:8080/health || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
environment:
# Database
DB_SERVER_URL: jdbc:postgresql://db:5432/layersdb
DB_SERVER_USERNAME: layers
DB_SERVER_PASSWORD: changeme
JPA_DDL_AUTO: update
STABILITY_SERVER_JPA_DDL_GUARD_ENABLED: "false"
# Admin panel (/godmode)
ADMIN_USERNAME: admin
ADMIN_PASSWORD: admin
# Domain
LAYERS_DOMAIN: localhost
LAYERS_APP_DOMAIN: localhost
APP_REDIRECT_URL: http://localhost:8080
APP_CORS_ALLOWED_ORIGINS: http://localhost:8080
depends_on:
db:
condition: service_healthy
volumes:
- layers_data:/root/layers_data
# Voice & video calls (LiveKit). Works out of the box — the application
# already knows the default hostname/keys. For a public-facing install,
# expose it over HTTPS (see "Voice & Video (LiveKit)" below).
livekit:
image: livekit/livekit-server:latest
hostname: layers-livekit
environment:
LIVEKIT_CONFIG: |
port: 7880
bind_addresses: [""]
rtc:
tcp_port: 7881
port_range_start: 50000
port_range_end: 50100
use_external_ip: true
keys:
layers_default_key: layers_default_secret_change_me_min_32_chars_xx
webhook:
api_key: layers_default_key
urls:
- http://layers:8080/v1/livekit/webhook
ports:
- "7880:7880"
- "7881:7881"
- "7882:7882/udp"
- "50000-50100:50000-50100/udp"
volumes:
pg_data:
layers_data:
Run:
docker compose up -d
Wait ~15 seconds, then open:
admin / admin)At this point Layers is running. Everything below is optional.
Before making the app accessible to anyone else, change these values in docker-compose.yml:
| What | Default | Action |
|---|---|---|
POSTGRES_PASSWORD + DB_SERVER_PASSWORD | changeme | Set a strong password (both must match) |
ADMIN_PASSWORD | admin | Set a strong admin password |
APP_JWT_SECRET | auto-generated | Already secure. Override only if you need a fixed value: openssl rand -base64 64 |
Then restart: docker compose up -d
If you access Layers from a domain (not localhost), update these 4 values:
LAYERS_DOMAIN: example.com
LAYERS_APP_DOMAIN: example.com
APP_REDIRECT_URL: https://example.com
APP_CORS_ALLOWED_ORIGINS: https://example.com
Then restart: docker compose up -d
Layers serves everything — web UI, API, admin panel, static files — from a single application on port 8080. No separate web server is required.
| Scenario | Nginx needed? |
|---|---|
| Local network, VPN, or intranet | No — use http://server-ip:8080 directly |
| Development and testing | No |
| Behind an existing corporate proxy | No — point your proxy to port 8080 |
| Public domain with HTTPS | Yes — Nginx terminates SSL, forwards to port 8080 |
If you don't need HTTPS, skip to the next section.
Nginx only terminates SSL on 443 and forwards to 8080. Do not serve static files, cache or rate-limit in Nginx — the app does that itself.
sudo apt install nginx certbot python3-certbot-nginx
sudo certbot certonly --nginx -d example.com -d www.example.com
Replace example.com with your domain. Add client_max_body_size 1000m; to http {} in /etc/nginx/nginx.conf (Nginx default 1m blocks uploads).
Site config — /etc/nginx/sites-available/layers.conf:
server {
listen 80;
server_name example.com;
location /.well-known/acme-challenge/ { root /var/www/certbot; }
location / { return 301 https://example.com$request_uri; }
}
server {
listen 443 ssl http2;
server_name example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options SAMEORIGIN always;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 300;
}
}
sudo ln -s /etc/nginx/sites-available/layers.conf /etc/nginx/sites-enabled/
sudo rm -f /etc/nginx/sites-enabled/default
sudo nginx -t && sudo systemctl reload nginx
Make sure your docker-compose.yml has the correct domain (see Step 4. Custom domain).
limit_req — the SPA loads dozens of assets per page; rate limiting blocks the UI. Brute-force protection is built in.sudo certbot renew --dry-runPull the new image and restart. Your data stays:
docker compose pull
docker compose up -d
Important: The volume must mount to
/root/layers_data(not/data). All Godmode settings, uploaded files, JWT secrets, and email templates are stored there. If the volume path is wrong, settings will be lost on every update.
docker compose pull updates images only. When a release adds a service (e.g. livekit), add its block from the template above, then:
docker compose up -d
Settings, database and files stay in the layers_data and pg_data volumes.
Stop everything and delete all data (database + files):
docker compose down -v
docker compose up -d
People are never limited: seats are not capped, data is never touched. Without a key a node runs 90 days at full speed; then the machine paths (transcription, export, conversion) slow down — people keep working, nothing is blocked, nothing phones home; the key is verified on your node, offline. Godmode → System → License shows the mode, the day counter and the key field; status also at GET /layers/license/status. Plans: 150 € / month up to 50 people, 400 € up to 200 — sumoffice.com/licensing.
Word and Excel files open inside Layers in SumDoc and SumSheet — the engines behind sumoffice.com: a .docx as pages in seconds, an .xlsx recalculated without Excel, macros and Power Query included. The editor service comes with the node license; see it on app.layers.md first.
Everything below is configured via environment variables. You only need to add the variables you want — everything has sensible defaults.
The livekit service in the install template runs on localhost out of the box — start a call, it works.
For a public install behind HTTPS, add two location blocks to your Nginx vhost (the LiveKit SDK strips prefixes from REST calls, sending them to /twirp/... on the root):
# WebSocket signaling
location /livekit/ {
proxy_pass http://127.0.0.1:7880/; # trailing slash matters
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 86400s;
}
# LiveKit REST API — backend SDK hits /twirp/ on root, not under /livekit/
location /twirp/ {
proxy_pass http://127.0.0.1:7880/twirp/;
proxy_set_header Host $host;
proxy_read_timeout 86400s;
}
Open the media ports on the host firewall:
ufw allow 7881/tcp
ufw allow 50000:50100/udp
In Godmode → Integrations → LiveKit:
wss://your-domain.com/livekit/ (trailing slash is mandatory — backend throws baseUrl must end in / without it).openssl rand -hex 16 # new API key
openssl rand -hex 24 # new secret (must be ≥32 chars)
LIVEKIT_CONFIG in compose and Godmode fields.Then: Save → Test connection (green toast Connected, 0 active rooms) → Enable LiveKit.
Alternative: dedicated subdomain kit.example.com → separate Nginx vhost, single location / proxying to 127.0.0.1:7880, URL wss://kit.example.com/ (no /twirp/ needed).
Diagnostics: GET /v1/livekit/status, Test connection in Godmode, HTTP 401 with matching keys → /twirp/ location missing.
Hosting gotchas (VPS with restrictive kernels/firewalls):
could not validate external IP — outbound UDP to STUN is blocked: replace use_external_ip: true with node_ip: <public-ip> in LIVEKIT_CONFIG.rtc.curl http://127.0.0.1:7880 returns "Connection refused" while the container runs — use "7880:7880" instead of "127.0.0.1:7880:7880" in port mapping, or enable: echo "net.ipv4.conf.all.route_localnet=1" | sudo tee /etc/sysctl.d/99-docker-localnet.conf && sudo sysctl --systemWithout email there are no registration confirmations, password resets or invitations.
SMTP (Gmail, Mailgun, any SMTP server):
# Add to layers service environment:
MAIL_PROVIDER: smtp # Enable SMTP email
SPRING_MAIL_HOST: smtp.gmail.com # Your SMTP server
SPRING_MAIL_PORT: "587" # SMTP port (587 for STARTTLS, 465 for SSL)
SPRING_MAIL_USERNAME: [email protected] # SMTP login
SPRING_MAIL_PASSWORD: your-app-password # SMTP password (use app-specific password for Gmail)
SPRING_MAIL_SMTP_AUTH: "true" # Enable authentication
SPRING_MAIL_SMTP_STARTTLS_ENABLE: "true" # Enable STARTTLS (port 587)
# SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_ENABLE: "true" # Use instead of STARTTLS for port 465
# APP_MAIL_FROM: [email protected] # Sender address (defaults to SMTP username)
SendGrid:
MAIL_PROVIDER: sendgrid
SENDGRID_API_KEY: SG.xxxxx # Your SendGrid API key
SENDGRID_FROM: [email protected] # Sender address
Mailchimp / Mandrill:
MAIL_PROVIDER: mailchimp
MAILCHIMP_API_KEY: md-xxxxx # Mandrill API key
All email settings can also be configured via Godmode → Platform → Email.
"Sign in with Google" on the login page; needs a Google Cloud Console OAuth 2.0 client.
GOOGLE_AUTH_ENABLED: "true" # Must be set to enable the login button
GOOGLE_CLIENT_ID: 123...apps.googleusercontent.com # OAuth client ID
GOOGLE_CLIENT_SECRET: GOCSPX-... # OAuth client secret
GOOGLE_REDIRECT_URI: https://example.com/auth/google/callback # Must match Google Console
Can also be configured via Godmode → Authentication → Google.
AI features (text generation, summaries) via the OpenAI API.
AI_ENABLED: "true" # Enable AI features
AI_PROVIDER: openai # openai (default) | ollama | custom
OPENAI_API_KEY: sk-proj-xxxxx # Your API key
OPENAI_MODEL: gpt-4o-mini # Model name (default: gpt-4o-mini)
All AI settings can also be configured at runtime via Godmode → Integrations → AI.
A local LLM instead of OpenAI — no API key, models run on your server.
Step 1. Add Ollama to your docker-compose.yml:
ollama:
image: ollama/ollama:latest
volumes:
- ollama_data:/root/.ollama
Add ollama_data: to your volumes: section.
Step 2. Start and pull a model:
docker compose up -d
docker exec layers-ollama-1 ollama pull qwen2.5:0.5b
Other models: llama3.2 (3B), gemma2:2b (2B), mistral (7B). Full list at ollama.com/library.
Step 3. Configure in Godmode:
Go to Godmode → Integrations → AI and select:
Ollama (local)http://ollama:11434Or via environment variables:
AI_ENABLED: "true"
AI_PROVIDER: ollama
OLLAMA_URL: http://ollama:11434 # Default if using Docker Compose service name
OPENAI_MODEL: qwen2.5:0.5b # Model to use (must be pulled first)
Note: Switching from LOCAL to S3 storage or from OpenAI to Ollama requires a container restart. Audio transcription (Whisper) is not supported with Ollama — only available with OpenAI.
"Sign in with Yandex"; create an app at oauth.yandex.ru/client/new. Add scopes login:email and login:info.
YANDEX_AUTH_ENABLED: "true"
YANDEX_CLIENT_ID: your-client-id
YANDEX_CLIENT_SECRET: your-client-secret
YANDEX_REDIRECT_URI: https://example.com/auth/yandex/callback
Can also be configured via Godmode → Authentication → Yandex.
Adds "Sign in with VK ID" button. Create an app in the VK ID console.
VK_AUTH_ENABLED: "true"
VK_CLIENT_ID: "51234567"
VK_CLIENT_SECRET: your-secure-key
VK_REDIRECT_URI: https://example.com/auth/vk/callback
Can also be configured via Godmode → Authentication → VK ID.
Adds "Sign in with Gosuslugi" button. Requires registration with the Ministry of Digital Development and a GOST/RSA certificate (p12/pfx format).
ESIA_AUTH_ENABLED: "true"
ESIA_CLIENT_ID: YOUR_SYSTEM_MNEMONIC
ESIA_REDIRECT_URI: https://example.com/auth/esia/callback
ESIA_CERT_PATH: /path/to/certificate.p12
ESIA_CERT_PASSWORD: your-cert-password
Can also be configured via Godmode → Authentication → ESIA.
Note: ESIA integration requires a registered information system and a qualified electronic signature certificate. This is not available for all organizations.
Image search when inserting images; the key stays on the backend.
UNSPLASH_ENABLED: "true" # Must be set to enable image search
UNSPLASH_ACCESS_KEY: your-key # Get at unsplash.com/developers
GIF search; same server-side proxy as Unsplash.
GIPHY_ENABLED: "true" # Must be set to enable GIF search
GIPHY_API_KEY: your-key # Get at developers.giphy.com
By default, uploads are stored locally in the Docker volume. Switch to S3 for cloud storage:
FILE_STORAGE_TYPE: S3 # Switch from LOCAL to S3
USE_AWS: "true" # Enable AWS SDK
S3_BUCKET_NAME: my-bucket # S3 bucket name
S3_REGION: eu-central-1 # AWS region
S3_ACCESS_KEY: AKIA... # AWS access key
S3_SECRET_KEY: your-secret # AWS secret key
Works with any S3-compatible storage (MinIO, DigitalOcean Spaces, etc.)
Note: Switching from LOCAL to S3 requires a container restart to initialize the S3 connection. Switching from S3 to LOCAL takes effect immediately.
S3 can also be configured via Godmode → System → Storage.
The default max upload size is 900 MB. To change it:
SPRING_SERVLET_MULTIPART_MAX_FILE_SIZE: "2GB"
SPRING_SERVLET_MULTIPART_MAX_REQUEST_SIZE: "2GB"
Can also be configured via Godmode → System → Storage → Max File Size.
If using Nginx, also update client_max_body_size in your Nginx config to match or exceed this value.
Full-text search; needs a running Elasticsearch.
SEARCH_ELASTICSEARCH_ENABLED: "true"
ES_HOST: elasticsearch # Elasticsearch host
ES_PORT: "9200" # Elasticsearch port
ES_USERNAME: elastic # Auth username (if enabled)
ES_PASSWORD: changeme # Auth password (if enabled)
Backend error tracking with Sentry.
USE_SENTRY: "true" # Required to enable error reporting
SENTRY_DSN: https://[email protected]/456 # Sentry DSN
SENTRY_ENVIRONMENT: production # Environment tag
SENTRY_TRACES_SAMPLE_RATE: "0.1" # % of requests traced (default: 0.0 = disabled)
Important:
USE_SENTRY: "true"must be set as an environment variable — configuring DSN in Godmode alone is not sufficient. The Sentry error transport is initialized at startup and requires this flag.
FRONTEND_ANALYTICS_YANDEX_METRIKA_ID: "12345" # Yandex.Metrika counter
FRONTEND_ANALYTICS_GTM_ID: GTM-XXXXXXX # Google Tag Manager
Customize the app name, logo, and social links:
FRONTEND_APP_NAME: "My Company" # App name shown in UI
FRONTEND_APP_URL: https://app.company.com # App URL for emails
FRONTEND_LOGO_FULL_URL: https://cdn.company.com/logo.png # Full logo URL
FRONTEND_LOGO_ICON_URL: https://cdn.company.com/icon.png # Icon/favicon URL
Social links (shown in footer/about):
FRONTEND_SOCIAL_FACEBOOK: https://facebook.com/mypage
FRONTEND_SOCIAL_INSTAGRAM: https://instagram.com/mypage
FRONTEND_SOCIAL_LINKEDIN: https://linkedin.com/company/myco
FRONTEND_SOCIAL_TELEGRAM: https://t.me/mychannel
FRONTEND_SOCIAL_DISCORD: https://discord.gg/myinvite
FRONTEND_SOCIAL_WHATSAPP: https://wa.me/123456789
Replace the db service with your own PostgreSQL:
DB_SERVER_URL: jdbc:postgresql://your-host:5432/your_db # Your database JDBC URL
DB_SERVER_USERNAME: your_user # Database user
DB_SERVER_PASSWORD: your_password # Database password
Remove the db service and pg_data volume from docker-compose.yml.
Lets Claude Code, Cursor and other AI tools use workspace data via Model Context Protocol.
# Add as a separate service:
layers-mcp:
image: sinups/layers-mcp-server:latest
depends_on:
- layers
environment:
LAYERS_BACKEND_BASEURL: http://layers:8080
LAYERS_BACKEND_FORWARDINCOMINGAUTH: "true"
LAYERS_BACKEND_REQUIREINCOMINGAUTH: "true"
SERVER_PORT: "8091"
Users create personal API tokens in Settings > Access Tokens.
For resellers: enables the Distribution section in Godmode with download pages.
LAYERS_SELLER: "true" # Enable Distribution in Godmode
LAYERS_REGION: "EU" # Region code (EU, RU, US) — affects social links
LAYERS_DOWNLOAD_DESKTOP_ENABLED: "true" # Show /download page
LAYERS_DOWNLOAD_DESKTOP_MAC_URL: https://cdn.example.com/Layers.dmg
LAYERS_DOWNLOAD_DESKTOP_WIN_URL: https://cdn.example.com/Layers-Setup.exe
LAYERS_DOWNLOAD_DESKTOP_LINUX_URL: https://cdn.example.com/Layers.AppImage
LAYERS_DOWNLOAD_SELFHOST_ENABLED: "true" # Show /download-install page
LAYERS_DOWNLOAD_SELFHOST_TAR_GZ_URL: /files/layers-server-latest.tar.gz
LAYERS_DOWNLOAD_SELFHOST_TAR_URL: /files/layers-server-latest.tar
PostgreSQL sets the password only when the volume is created. If you changed POSTGRES_PASSWORD later:
Option A — reset database (loses data):
docker compose down -v
docker compose up -d
Option B — change password without losing data:
docker exec -it layers-db-1 psql -U postgres -c "ALTER USER layers PASSWORD 'your_new_password'"
Check logs:
docker compose logs layers
Your APP_CORS_ALLOWED_ORIGINS doesn't match the URL in your browser.
The value must match exactly (including https:// and port if any).
A subdomain of layers.md next to app.layers.md gets conflicting cookies: set LAYERS_DOMAIN and LAYERS_APP_DOMAIN to your domain and restart.
If the server fails to start with violates check constraint "workspaces_plan_check":
docker exec -it layers-db-1 psql -U layers -d layersdb \
-c "ALTER TABLE workspaces DROP CONSTRAINT IF EXISTS workspaces_plan_check;"
docker compose restart layers
The app needs ~15–60 s to start; the healthcheck restarts it if unresponsive.
docker compose logs layers --tail=20
If you see Started LayersApplication, the server is ready.
ERR_NOT_MULTIPART: the upload was truncated — Nginx client_max_body_size is smaller than the file. Add:
client_max_body_size 1000m;
413: Nginx or Spring rejects the file. Check both:
client_max_body_sizeSPRING_SERVLET_MULTIPART_MAX_FILE_SIZE env var (default 900MB)The JWT secret lives in the layers_data volume; lose the volume and everyone must log in again.
| Tag | Description |
|---|---|
latest | Latest stable release (recommended for all installs); from 2.1.184-unified-20260920-cve: license page, 90-day node mode, Docker Scout 0 critical / 0 high |
{VERSION} | Pinned semver release (e.g. 1.0.0) |
Content type
Image
Digest
sha256:9dda9dd98…
Size
666.5 MB
Last updated
3 days ago
docker pull hissih/layers