ManaWG
Stack
Free, open-source app for shared flats: expenses, cleaning rota, shopping list, calendar and chat. Runs the app with PostgreSQL, sign-in and image storage. Source: https://github.com/Abuarchiv/manawg
Source details
Configuration
TypeComposelinuxAPP_URL=http://localhostSITE_ADDRESS=:80HTTP_PORT=80HTTPS_PORT=443POSTGRES_PASSWORD=''JWT_SECRET=''SMTP_HOST=''SMTP_PORT=587SMTP_USER=''SMTP_PASS=''[email protected]LEGAL_URL=''COMPOSE_PROFILES=unless-stoppedNotes
openssl rand -hex 24 or any password generator; it is fixed when the database is first created, so keep it. JWT_SECRET: 32 or more characters; the stack derives its API keys from it and needs no key generation. Both are stored in the stack's environment. Every other secret is derived. Behind HTTPS, set APP_URL to the public address and SITE_ADDRESS to the bare domain for a Let's Encrypt certificate; for a reverse proxy keep :80. Details: docs/self-hosting.md.Services
keys
Configuration
Image${MANAWG_IMAGE:-ghcr.io/abuarchiv/manawg}:${MANAWG_VERSION:-latest}node,/keys.mjs/keys : keysJWT_SECRET=${JWT_SECRET:?set JWT_SECRET (32 or more characters)}POSTGRES_PASSWORD=${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD (letters and digits)}MANAWG_APP_DB_PASSWORD=${MANAWG_APP_DB_PASSWORD:-}MEMBER_SESSION_SECRET=${MEMBER_SESSION_SECRET:-}CRON_SECRET=${CRON_SECRET:-}nodb
Configuration
Imagesupabase/postgres:17.11.0.018postgres,-c,config_file=/etc/postgresql/postgresql.conf,-c,log_min_messages=fatal/var/lib/postgresql/data : db-data/etc/postgresql-custom : db-configPOSTGRES_HOST=/var/run/postgresqlPOSTGRES_PORT=5432PGPORT=5432POSTGRES_DB=postgresPGDATABASE=postgresPOSTGRES_PASSWORD=${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}PGPASSWORD=${POSTGRES_PASSWORD}unless-stoppedImage details
auth
Configuration
Imagesupabase/gotrue:v2.197.0GOTRUE_API_HOST=0.0.0.0GOTRUE_API_PORT=9999API_EXTERNAL_URL=${APP_URL}/auth/v1GOTRUE_SITE_URL=${APP_URL}GOTRUE_URI_ALLOW_LIST=${APP_URL}/**GOTRUE_DB_DRIVER=postgresGOTRUE_DB_DATABASE_URL=postgres://supabase_auth_admin:${POSTGRES_PASSWORD}@db:5432/postgresGOTRUE_DISABLE_SIGNUP=falseGOTRUE_JWT_SECRET=${JWT_SECRET}GOTRUE_JWT_EXP=3600GOTRUE_JWT_AUD=authenticatedGOTRUE_JWT_ADMIN_ROLES=service_roleGOTRUE_JWT_DEFAULT_GROUP_NAME=authenticatedGOTRUE_JWT_ISSUER=${APP_URL}/auth/v1GOTRUE_SECURITY_REFRESH_TOKEN_ROTATION_ENABLED=trueGOTRUE_SECURITY_REFRESH_TOKEN_REUSE_INTERVAL=10GOTRUE_RATE_LIMIT_HEADER=X-Forwarded-ForGOTRUE_EXTERNAL_EMAIL_ENABLED=trueGOTRUE_MAILER_AUTOCONFIRM=falseGOTRUE_PASSWORD_MIN_LENGTH=8GOTRUE_MAILER_SECURE_EMAIL_CHANGE_ENABLED=trueGOTRUE_MAILER_OTP_LENGTH=8GOTRUE_MAILER_OTP_EXP=3600GOTRUE_MAILER_URLPATHS_INVITE=/auth/v1/verifyGOTRUE_MAILER_URLPATHS_CONFIRMATION=/auth/v1/verifyGOTRUE_MAILER_URLPATHS_RECOVERY=/auth/v1/verifyGOTRUE_MAILER_URLPATHS_EMAIL_CHANGE=/auth/v1/verifyGOTRUE_SMTP_HOST=${SMTP_HOST:?set SMTP_HOST (docs/self-hosting.md#portainer)}GOTRUE_SMTP_PORT=${SMTP_PORT:-587}GOTRUE_SMTP_USER=${SMTP_USER:-}GOTRUE_SMTP_PASS=${SMTP_PASS:-}GOTRUE_SMTP_ADMIN_EMAIL=${SMTP_ADMIN_EMAIL}GOTRUE_SMTP_SENDER_NAME=${SMTP_SENDER_NAME:-ManaWG}GOTRUE_MAILER_TEMPLATES_CONFIRMATION=http://gateway:8000/mail-templates/confirmation.htmlGOTRUE_MAILER_TEMPLATES_RECOVERY=http://gateway:8000/mail-templates/recovery.htmlGOTRUE_MAILER_TEMPLATES_MAGIC_LINK=http://gateway:8000/mail-templates/magic_link.htmlGOTRUE_MAILER_TEMPLATES_EMAIL_CHANGE=http://gateway:8000/mail-templates/email_change.htmlGOTRUE_MAILER_TEMPLATES_PASSWORD_CHANGED_NOTIFICATION=http://gateway:8000/mail-templates/password_changed.htmlGOTRUE_MAILER_SUBJECTS_CONFIRMATION={{ if eq .Data.registration "code" }}{{ if eq .Data.locale "en" }}{{ .Token }} is your code to create the account{{ else if eq .Data.locale "es" }}{{ .Token }} es tu código para crear la cuenta{{ else }}{{ .Token }} ist dein Code zum Erstellen des Kontos{{ end }}{{ else }}{{ if eq .Data.locale "en" }}Confirm your email address{{ else if eq .Data.locale "es" }}Confirma tu dirección de correo{{ else }}Bestätige deine E-Mail-Adresse{{ end }}{{ end }}GOTRUE_MAILER_SUBJECTS_RECOVERY={{ if eq .Data.locale "en" }}Set a new password{{ else if eq .Data.locale "es" }}Elige una contraseña nueva{{ else }}Neues Passwort festlegen{{ end }}GOTRUE_MAILER_SUBJECTS_MAGIC_LINK={{ if eq .Data.locale "en" }}{{ .Token }} is your sign-in code{{ else if eq .Data.locale "es" }}{{ .Token }} es tu código de inicio de sesión{{ else }}{{ .Token }} ist dein Anmeldecode{{ end }}GOTRUE_MAILER_SUBJECTS_EMAIL_CHANGE={{ if eq .Data.locale "en" }}Confirm the change of your email address{{ else if eq .Data.locale "es" }}Confirma el cambio de tu dirección de correo{{ else }}Bestätige die Änderung deiner E-Mail-Adresse{{ end }}GOTRUE_MAILER_SUBJECTS_PASSWORD_CHANGED_NOTIFICATION={{ if eq .Data.locale "en" }}Your password was changed{{ else if eq .Data.locale "es" }}Tu contraseña se ha cambiado{{ else }}Dein Passwort wurde geändert{{ end }}GOTRUE_MAILER_NOTIFICATIONS_PASSWORD_CHANGED_ENABLED=trueGOTRUE_EXTERNAL_GOOGLE_ENABLED=${GOOGLE_ENABLED:-false}GOTRUE_EXTERNAL_GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID:-}GOTRUE_EXTERNAL_GOOGLE_SECRET=${GOOGLE_SECRET:-}GOTRUE_EXTERNAL_GOOGLE_REDIRECT_URI=${APP_URL}/auth/v1/callbackunless-stoppedImage details
storage
Configuration
Imagesupabase/storage-api:v1.89.13node,dist/start/server.jssh,-c,. /keys/storage.env && exec docker-entrypoint.sh "$$@",sh/var/lib/storage : storage-data/keys : keysAUTH_JWT_SECRET=${JWT_SECRET}DATABASE_URL=postgres://supabase_storage_admin:${POSTGRES_PASSWORD}@db:5432/postgresSTORAGE_PUBLIC_URL=${APP_URL}REQUEST_ALLOW_X_FORWARDED_PATH=trueFILE_SIZE_LIMIT=52428800STORAGE_BACKEND=fileFILE_STORAGE_BACKEND_PATH=/var/lib/storageGLOBAL_S3_BUCKET=stubTENANT_ID=stubREGION=stubENABLE_IMAGE_TRANSFORMATION=falseunless-stoppedImage details
gateway
Configuration
Imagecaddy:2.11.8-alpine${HTTP_BIND:-}${HTTP_PORT:-80}:80${HTTP_BIND:-}${HTTPS_PORT:-443}:443${HTTP_BIND:-}${HTTPS_PORT:-443}:443/udp/srv/mail-templates : mail-templates/data : caddy-data/config : caddy-configSITE_ADDRESS=${SITE_ADDRESS:-:80}TRUSTED_PROXIES=${TRUSTED_PROXIES:-127.0.0.1/32}unless-stoppedImage details
setup
Configuration
Image${MANAWG_IMAGE:-ghcr.io/abuarchiv/manawg}:${MANAWG_VERSION:-latest}sh,-c,. /keys/setup.env && exec node scripts/selfhost/setup.mjs/app/mail-templates : mail-templates/keys : keysDATABASE_MIGRATION_URL=postgres://postgres:${POSTGRES_PASSWORD}@db:5432/postgresSUPABASE_INTERNAL_URL=http://gateway:8000SUPABASE_STORAGE_BUCKET=${STORAGE_BUCKET:-private-images}NEXT_PUBLIC_APP_URL=${APP_URL}NEXT_PUBLIC_LEGAL_URL=${LEGAL_URL:-}MAIL_TEMPLATES_DIR=/app/mail-templatesnoapp
Configuration
Image${MANAWG_IMAGE:-ghcr.io/abuarchiv/manawg}:${MANAWG_VERSION:-latest}sh,-c,. /keys/app.env && node scripts/selfhost/public-env.mjs && exec node server.js/keys : keysNEXT_PUBLIC_APP_URL=${APP_URL:?set APP_URL}NEXT_PUBLIC_SUPABASE_URL=${APP_URL}NEXT_PUBLIC_LEGAL_URL=${LEGAL_URL:-}NEXT_PUBLIC_SUPPORT_EMAIL=${SUPPORT_EMAIL:-}SMTP_ADMIN_EMAIL=${SMTP_ADMIN_EMAIL}GOOGLE_SIGN_IN=${GOOGLE_ENABLED:-false}SUPABASE_INTERNAL_URL=http://gateway:8000SUPABASE_STORAGE_BUCKET=${STORAGE_BUCKET:-private-images}MEMBER_SESSION_SECRET_PREVIOUS=${MEMBER_SESSION_SECRET_PREVIOUS:-}SMTP_HOST=${SMTP_HOST:?set SMTP_HOST (docs/self-hosting.md#portainer)}SMTP_PORT=${SMTP_PORT:-587}SMTP_USER=${SMTP_USER:-}SMTP_PASS=${SMTP_PASS:-}STATEMENT_EMAIL_FROM=${SMTP_SENDER_NAME:-ManaWG} <${SMTP_ADMIN_EMAIL}>VAPID_PRIVATE_KEY=${VAPID_PRIVATE_KEY:-}VAPID_SUBJECT=${VAPID_SUBJECT:-mailto:${SMTP_ADMIN_EMAIL}}unless-stoppedcron
Configuration
Image${MANAWG_IMAGE:-ghcr.io/abuarchiv/manawg}:${MANAWG_VERSION:-latest}sh,-c,. /keys/cron.env && exec node scripts/selfhost/cron.mjs/keys : keysCRON_TARGET_URL=http://app:3000unless-stoppedmailpit
Configuration
Imageaxllent/mailpit:v1.33.0127.0.0.1:${MAILPIT_PORT:-8025}:8025unless-stoppedImage details
Standalone Install
Select an install method, to see config/commands for deploying ManaWG
Install on Portainer
Import all app templates into your Portainer instance, for easy 1-click deploys
- Ensure both Docker and Portainer are installed, and up-to-date
- Log into your Portainer web UI
- Under Settings → App Templates, paste the below URL
- Head to Home → App Templates, and the list of apps will show up
- Select ManaWG, fill in any config options, and hit Deploy
Template Import URL
https://raw.githubusercontent.com/Lissy93/portainer-templates/main/templates.json
Show Me
Original stackfile
The compose file this template deploys, straight from its repo:
# ManaWG for Portainer: the same stack as ../../compose.yaml, deployable from a Portainer
# repository stack or web editor on any Docker host. Docs: docs/self-hosting.md#portainer
#
# What differs from compose.yaml:
# - no bind mounts and no build: the gateway config and the database init script are inline
# `configs`, and the stack only pulls images. Keep them equal to ../Caddyfile and
# ../db/roles.sql (./check.sh compares them).
# - no ./docker/setup.sh: the `keys` service signs ANON_KEY and SERVICE_ROLE_KEY from JWT_SECRET
# and derives the other secrets, so you only enter POSTGRES_PASSWORD and JWT_SECRET.
# - all settings come from the stack's environment variables (Portainer: "Environment
# variables"), not from a .env file.
x-logging: &logging
driver: json-file
options:
max-size: 10m
max-file: "3"
x-app: &app
logging: *logging
image: ${MANAWG_IMAGE:-ghcr.io/abuarchiv/manawg}:${MANAWG_VERSION:-latest}
services:
# Creates the keys and derived secrets every other service reads; exits when done. Runs as root
# because it writes the empty named volume; the files are readable by the other services.
keys:
<<: *app
user: "0"
command: ["node", "/keys.mjs"]
restart: "no"
environment:
JWT_SECRET: ${JWT_SECRET:?set JWT_SECRET (32 or more characters)}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD (letters and digits)}
MANAWG_APP_DB_PASSWORD: ${MANAWG_APP_DB_PASSWORD:-}
MEMBER_SESSION_SECRET: ${MEMBER_SESSION_SECRET:-}
CRON_SECRET: ${CRON_SECRET:-}
configs:
- source: keys_script
target: /keys.mjs
volumes:
- keys:/keys
db:
image: supabase/postgres:17.11.0.018
restart: unless-stopped
logging: *logging
command: ["postgres", "-c", "config_file=/etc/postgresql/postgresql.conf", "-c", "log_min_messages=fatal"]
environment:
POSTGRES_HOST: /var/run/postgresql
POSTGRES_PORT: 5432
PGPORT: 5432
POSTGRES_DB: postgres
PGDATABASE: postgres
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}
PGPASSWORD: ${POSTGRES_PASSWORD}
depends_on:
keys:
condition: service_completed_successfully
volumes:
- db-data:/var/lib/postgresql/data
- db-config:/etc/postgresql-custom
configs:
- source: roles
target: /docker-entrypoint-initdb.d/init-scripts/99-roles.sql
healthcheck:
test: ["CMD", "pg_isready", "-U", "postgres", "-h", "localhost"]
interval: 5s
timeout: 5s
retries: 20
auth:
image: supabase/gotrue:v2.197.0
restart: unless-stopped
logging: *logging
depends_on:
db:
condition: service_healthy
environment:
GOTRUE_API_HOST: 0.0.0.0
GOTRUE_API_PORT: 9999
API_EXTERNAL_URL: ${APP_URL}/auth/v1
GOTRUE_SITE_URL: ${APP_URL}
GOTRUE_URI_ALLOW_LIST: ${APP_URL}/**
GOTRUE_DB_DRIVER: postgres
GOTRUE_DB_DATABASE_URL: postgres://supabase_auth_admin:${POSTGRES_PASSWORD}@db:5432/postgres
GOTRUE_DISABLE_SIGNUP: "false"
GOTRUE_JWT_SECRET: ${JWT_SECRET}
GOTRUE_JWT_EXP: 3600
GOTRUE_JWT_AUD: authenticated
GOTRUE_JWT_ADMIN_ROLES: service_role
GOTRUE_JWT_DEFAULT_GROUP_NAME: authenticated
GOTRUE_JWT_ISSUER: ${APP_URL}/auth/v1
GOTRUE_SECURITY_REFRESH_TOKEN_ROTATION_ENABLED: "true"
GOTRUE_SECURITY_REFRESH_TOKEN_REUSE_INTERVAL: 10
# Per-visitor rate limits: the gateway sets X-Forwarded-For, the app passes it on.
GOTRUE_RATE_LIMIT_HEADER: X-Forwarded-For
GOTRUE_EXTERNAL_EMAIL_ENABLED: "true"
GOTRUE_MAILER_AUTOCONFIRM: "false"
# Matches NEW_PASSWORD_MIN_LENGTH in the sign-up and reset forms.
GOTRUE_PASSWORD_MIN_LENGTH: 8
GOTRUE_MAILER_SECURE_EMAIL_CHANGE_ENABLED: "true"
# The sign-in form expects 8-digit codes.
GOTRUE_MAILER_OTP_LENGTH: 8
GOTRUE_MAILER_OTP_EXP: 3600
# Links in mails go through the gateway's public /auth/v1 routes.
GOTRUE_MAILER_URLPATHS_INVITE: /auth/v1/verify
GOTRUE_MAILER_URLPATHS_CONFIRMATION: /auth/v1/verify
GOTRUE_MAILER_URLPATHS_RECOVERY: /auth/v1/verify
GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE: /auth/v1/verify
GOTRUE_SMTP_HOST: ${SMTP_HOST:?set SMTP_HOST (docs/self-hosting.md#portainer)}
GOTRUE_SMTP_PORT: ${SMTP_PORT:-587}
GOTRUE_SMTP_USER: ${SMTP_USER:-}
GOTRUE_SMTP_PASS: ${SMTP_PASS:-}
GOTRUE_SMTP_ADMIN_EMAIL: ${SMTP_ADMIN_EMAIL}
GOTRUE_SMTP_SENDER_NAME: ${SMTP_SENDER_NAME:-ManaWG}
# Templates from supabase/templates, rendered for this instance by the setup service.
GOTRUE_MAILER_TEMPLATES_CONFIRMATION: http://gateway:8000/mail-templates/confirmation.html
GOTRUE_MAILER_TEMPLATES_RECOVERY: http://gateway:8000/mail-templates/recovery.html
GOTRUE_MAILER_TEMPLATES_MAGIC_LINK: http://gateway:8000/mail-templates/magic_link.html
GOTRUE_MAILER_TEMPLATES_EMAIL_CHANGE: http://gateway:8000/mail-templates/email_change.html
GOTRUE_MAILER_TEMPLATES_PASSWORD_CHANGED_NOTIFICATION: http://gateway:8000/mail-templates/password_changed.html
# Same subjects as supabase/templates/subjects.json.
GOTRUE_MAILER_SUBJECTS_CONFIRMATION: '{{ if eq .Data.registration "code" }}{{ if eq .Data.locale "en" }}{{ .Token }} is your code to create the account{{ else if eq .Data.locale "es" }}{{ .Token }} es tu código para crear la cuenta{{ else }}{{ .Token }} ist dein Code zum Erstellen des Kontos{{ end }}{{ else }}{{ if eq .Data.locale "en" }}Confirm your email address{{ else if eq .Data.locale "es" }}Confirma tu dirección de correo{{ else }}Bestätige deine E-Mail-Adresse{{ end }}{{ end }}'
GOTRUE_MAILER_SUBJECTS_RECOVERY: '{{ if eq .Data.locale "en" }}Set a new password{{ else if eq .Data.locale "es" }}Elige una contraseña nueva{{ else }}Neues Passwort festlegen{{ end }}'
GOTRUE_MAILER_SUBJECTS_MAGIC_LINK: '{{ if eq .Data.locale "en" }}{{ .Token }} is your sign-in code{{ else if eq .Data.locale "es" }}{{ .Token }} es tu código de inicio de sesión{{ else }}{{ .Token }} ist dein Anmeldecode{{ end }}'
GOTRUE_MAILER_SUBJECTS_EMAIL_CHANGE: '{{ if eq .Data.locale "en" }}Confirm the change of your email address{{ else if eq .Data.locale "es" }}Confirma el cambio de tu dirección de correo{{ else }}Bestätige die Änderung deiner E-Mail-Adresse{{ end }}'
GOTRUE_MAILER_SUBJECTS_PASSWORD_CHANGED_NOTIFICATION: '{{ if eq .Data.locale "en" }}Your password was changed{{ else if eq .Data.locale "es" }}Tu contraseña se ha cambiado{{ else }}Dein Passwort wurde geändert{{ end }}'
GOTRUE_MAILER_NOTIFICATIONS_PASSWORD_CHANGED_ENABLED: "true"
# Optional Google sign-in; register ${APP_URL}/auth/v1/callback as redirect URI at Google.
GOTRUE_EXTERNAL_GOOGLE_ENABLED: ${GOOGLE_ENABLED:-false}
GOTRUE_EXTERNAL_GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
GOTRUE_EXTERNAL_GOOGLE_SECRET: ${GOOGLE_SECRET:-}
GOTRUE_EXTERNAL_GOOGLE_REDIRECT_URI: ${APP_URL}/auth/v1/callback
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:9999/health"]
interval: 5s
timeout: 5s
retries: 20
storage:
image: supabase/storage-api:v1.89.13
restart: unless-stopped
logging: *logging
depends_on:
db:
condition: service_healthy
keys:
condition: service_completed_successfully
# The image's own entrypoint and command, after loading ANON_KEY and SERVICE_KEY.
entrypoint: ["sh", "-c", ". /keys/storage.env && exec docker-entrypoint.sh \"$$@\"", "sh"]
command: ["node", "dist/start/server.js"]
environment:
AUTH_JWT_SECRET: ${JWT_SECRET}
DATABASE_URL: postgres://supabase_storage_admin:${POSTGRES_PASSWORD}@db:5432/postgres
STORAGE_PUBLIC_URL: ${APP_URL}
REQUEST_ALLOW_X_FORWARDED_PATH: "true"
FILE_SIZE_LIMIT: 52428800
STORAGE_BACKEND: file
FILE_STORAGE_BACKEND_PATH: /var/lib/storage
GLOBAL_S3_BUCKET: stub
TENANT_ID: stub
REGION: stub
ENABLE_IMAGE_TRANSFORMATION: "false"
volumes:
- storage-data:/var/lib/storage
- keys:/keys:ro
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:5000/status"]
interval: 5s
timeout: 5s
retries: 20
gateway:
image: caddy:2.11.8-alpine
restart: unless-stopped
logging: *logging
depends_on:
- auth
- storage
ports:
- ${HTTP_BIND:-}${HTTP_PORT:-80}:80
- ${HTTP_BIND:-}${HTTPS_PORT:-443}:443
- ${HTTP_BIND:-}${HTTPS_PORT:-443}:443/udp
environment:
SITE_ADDRESS: ${SITE_ADDRESS:-:80}
TRUSTED_PROXIES: ${TRUSTED_PROXIES:-127.0.0.1/32}
volumes:
- mail-templates:/srv/mail-templates:ro
- caddy-data:/data
- caddy-config:/config
configs:
- source: caddyfile
target: /etc/caddy/Caddyfile
# Runtime role, migrations, storage bucket and mail templates; exits when done.
setup:
<<: *app
command: ["sh", "-c", ". /keys/setup.env && exec node scripts/selfhost/setup.mjs"]
restart: "no"
depends_on:
keys:
condition: service_completed_successfully
db:
condition: service_healthy
auth:
condition: service_healthy
storage:
condition: service_healthy
gateway:
condition: service_started
environment:
DATABASE_MIGRATION_URL: postgres://postgres:${POSTGRES_PASSWORD}@db:5432/postgres
SUPABASE_INTERNAL_URL: http://gateway:8000
SUPABASE_STORAGE_BUCKET: ${STORAGE_BUCKET:-private-images}
NEXT_PUBLIC_APP_URL: ${APP_URL}
NEXT_PUBLIC_LEGAL_URL: ${LEGAL_URL:-}
MAIL_TEMPLATES_DIR: /app/mail-templates
volumes:
- mail-templates:/app/mail-templates
- keys:/keys:ro
app:
<<: *app
restart: unless-stopped
# The image's own command, after loading the keys.
command: ["sh", "-c", ". /keys/app.env && node scripts/selfhost/public-env.mjs && exec node server.js"]
depends_on:
setup:
condition: service_completed_successfully
environment:
NEXT_PUBLIC_APP_URL: ${APP_URL:?set APP_URL}
NEXT_PUBLIC_SUPABASE_URL: ${APP_URL}
NEXT_PUBLIC_LEGAL_URL: ${LEGAL_URL:-}
NEXT_PUBLIC_SUPPORT_EMAIL: ${SUPPORT_EMAIL:-}
SMTP_ADMIN_EMAIL: ${SMTP_ADMIN_EMAIL}
GOOGLE_SIGN_IN: ${GOOGLE_ENABLED:-false}
SUPABASE_INTERNAL_URL: http://gateway:8000
SUPABASE_STORAGE_BUCKET: ${STORAGE_BUCKET:-private-images}
MEMBER_SESSION_SECRET_PREVIOUS: ${MEMBER_SESSION_SECRET_PREVIOUS:-}
SMTP_HOST: ${SMTP_HOST:?set SMTP_HOST (docs/self-hosting.md#portainer)}
SMTP_PORT: ${SMTP_PORT:-587}
SMTP_USER: ${SMTP_USER:-}
SMTP_PASS: ${SMTP_PASS:-}
STATEMENT_EMAIL_FROM: ${SMTP_SENDER_NAME:-ManaWG} <${SMTP_ADMIN_EMAIL}>
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY:-}
VAPID_SUBJECT: ${VAPID_SUBJECT:-mailto:${SMTP_ADMIN_EMAIL}}
volumes:
- keys:/keys:ro
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/api/health').then((r) => process.exit(r.ok ? 0 : 1), () => process.exit(1))"]
interval: 15s
timeout: 5s
retries: 10
start_period: 20s
# Calls the /api/cron/* routes on the schedules in apps/web/vercel.json (UTC).
cron:
<<: *app
command: ["sh", "-c", ". /keys/cron.env && exec node scripts/selfhost/cron.mjs"]
restart: unless-stopped
depends_on:
app:
condition: service_healthy
environment:
CRON_TARGET_URL: http://app:3000
volumes:
- keys:/keys:ro
# Local mail catch-all (profile "mailpit"): every mail lands at http://localhost:${MAILPIT_PORT:-8025}
# on the Docker host. Enable it with COMPOSE_PROFILES=mailpit and SMTP_HOST=mailpit, SMTP_PORT=1025.
mailpit:
image: axllent/mailpit:v1.33.0
restart: unless-stopped
profiles: ["mailpit"]
logging: *logging
ports:
- 127.0.0.1:${MAILPIT_PORT:-8025}:8025
volumes:
keys:
db-data:
db-config:
storage-data:
caddy-data:
caddy-config:
mail-templates:
configs:
# Same as docker/Caddyfile.
caddyfile:
content: |
# Gateway of the self-hosted stack (compose.yaml).
#
# Public site (SITE_ADDRESS): the app, plus only those Supabase routes a browser has to reach
# itself. A domain as SITE_ADDRESS gets a Let's Encrypt certificate automatically.
# Internal listener :8000 (container network only): the full Auth and Storage APIs for the app's
# server, and the rendered mail templates for Auth.
{
# TRUSTED_PROXIES: addresses of your own reverse proxy in front of the gateway, so its
# X-Forwarded-* headers (client address, https) reach the app. Strict mode picks the client
# address as the rightmost entry of X-Forwarded-For that is not a trusted proxy, so a visitor
# cannot choose it by sending the header.
servers {
trusted_proxies static {$$TRUSTED_PROXIES:127.0.0.1/32}
trusted_proxies_strict
}
servers :8000 {
trusted_proxies static private_ranges
}
}
{$$SITE_ADDRESS} {
# Mail confirmation links and OAuth redirects.
@auth path /auth/v1/verify /auth/v1/callback /auth/v1/authorize
handle @auth {
uri strip_prefix /auth/v1
# GoTrue keys its rate limit on the whole X-Forwarded-For value: send only the client.
reverse_proxy auth:9999 {
header_up X-Forwarded-For {client_ip}
}
}
# Signed image uploads straight from the browser.
@upload path /storage/v1/object/upload/sign/*
handle @upload {
uri strip_prefix /storage/v1
reverse_proxy storage:5000
}
handle {
# The app keys its rate limits on the last X-Forwarded-For entry: send only the client.
reverse_proxy app:3000 {
header_up X-Forwarded-For {client_ip}
}
}
}
:8000 {
handle_path /auth/v1/* {
reverse_proxy auth:9999
}
handle_path /storage/v1/* {
reverse_proxy storage:5000
}
handle_path /mail-templates/* {
root * /srv/mail-templates
file_server
}
}
# Same as docker/db/roles.sql.
roles:
content: |
-- Runs once when the database volume is created: Auth and Storage log in with the database password.
\set pgpass `echo "$$POSTGRES_PASSWORD"`
ALTER USER supabase_auth_admin WITH PASSWORD :'pgpass';
ALTER USER supabase_storage_admin WITH PASSWORD :'pgpass';
# Run by the `keys` service.
keys_script:
content: |
// Writes the keys and secrets the ManaWG stack needs, so nothing has to be generated by hand.
// ANON_KEY and SERVICE_ROLE_KEY are HS256 JWTs signed with JWT_SECRET (what docker/setup.sh does
// with openssl). The app database password, MEMBER_SESSION_SECRET and CRON_SECRET are derived from
// JWT_SECRET unless the stack sets them. The output is the same on every start.
import { createHmac } from "node:crypto";
import { chmodSync, writeFileSync } from "node:fs";
const fail = (message) => {
console.error("[keys] " + message);
process.exit(1);
};
const env = (name) => (process.env[name] || "").trim();
const plain = (name, value) => {
if (!/^[A-Za-z0-9]{16,}$$/.test(value)) {
fail(name + " must be 16 or more letters and digits (for example the output of: openssl rand -hex 24).");
}
return value;
};
const jwtSecret = env("JWT_SECRET");
if (jwtSecret.length < 32) fail("JWT_SECRET must have 32 or more characters.");
plain("POSTGRES_PASSWORD", env("POSTGRES_PASSWORD"));
const b64 = (input) => Buffer.from(input).toString("base64url");
const sign = (data, key) => createHmac("sha256", key).update(data).digest();
const jwt = (role) => {
// Fixed issue and expiry dates (2025 to 2125) keep the key the same on every start.
const head = b64(JSON.stringify({ alg: "HS256", typ: "JWT" }));
const body = b64(JSON.stringify({ role, iss: "supabase", iat: 1735689600, exp: 4889289600 }));
return head + "." + body + "." + b64(sign(head + "." + body, jwtSecret));
};
const secret = (name) =>
env(name) ? plain(name, env(name)) : sign("manawg/" + name, jwtSecret).toString("hex");
const anon = jwt("anon");
const service = jwt("service_role");
const appDbPassword = secret("MANAWG_APP_DB_PASSWORD");
const memberSecret = secret("MEMBER_SESSION_SECRET");
const cronSecret = secret("CRON_SECRET");
const files = {
"storage.env": { ANON_KEY: anon, SERVICE_KEY: service },
"setup.env": { MANAWG_APP_DB_PASSWORD: appDbPassword, SUPABASE_SERVICE_ROLE_KEY: service },
"app.env": {
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY: anon,
SUPABASE_SERVICE_ROLE_KEY: service,
POSTGRES_URL: "postgres://manawg_app:" + appDbPassword + "@db:5432/postgres",
MEMBER_SESSION_SECRET: memberSecret,
CRON_SECRET: cronSecret,
},
"cron.env": { CRON_SECRET: cronSecret },
};
for (const [name, values] of Object.entries(files)) {
const text = Object.entries(values).map(([key, value]) => "export " + key + "='" + value + "'\n").join("");
writeFileSync("/keys/" + name, text);
chmodSync("/keys/" + name, 0o644);
}
console.error("[keys] Keys and secrets are ready.");
Or deploy it directly from the source:
git clone https://github.com/Abuarchiv/manawg
cd manawg
docker compose -f docker/portainer/compose.yaml up -dMore install options in our documentation, or see Abuarchiv/manawg for app-specific guidance.
Container Documentation
db Documentation
Unmodified Postgres with some useful plugins.
gateway Documentation
Caddy 2 is a powerful, enterprise-ready, open source web server with automatic HTTPS written in Go.
mailpit Documentation
An email and SMTP testing tool with API for developers
Check the logs first
Nine times out of ten the logs tell you exactly what went wrong.
- In Portainer, go to Containers, click the container, then Logs. Or run
docker logs <container> - Exit codes help too:
137means killed, usually out of memory.126or127means the command inside the image is broken.
Image won't pull
Test the pull directly on the host: docker pull ${MANAWG_IMAGE:-ghcr.io/abuarchiv/manawg}:${MANAWG_VERSION:-latest}
- "manifest unknown" means the tag no longer exists.
- "toomanyrequests" is the Docker Hub rate limit. Log in with
docker loginto raise it. - "no space left on device" means a full disk. Reclaim space with
docker system prune
"exec format error"
This means the image was built for a different CPU architecture than your server.
- Check yours with
uname -m: x86_64 is amd64, aarch64 is arm64. Raspberry Pi and other ARM boards are the usual culprits.
Container keeps restarting
The unless-stopped restart policy relaunches the app after every crash, so the real error can scroll past.
- Check the logs right after a restart, the last few lines before it died are the useful ones.
- Get the exit code with
docker inspect <container> --format '{{.State.ExitCode}}' - Still stuck? Redeploy once with the restart policy set to
noso the failure stays visible.
Stack won't deploy
Compose stacks fail fast on small mistakes, and Portainer shows the reason just above the editor.
- YAML only accepts spaces for indentation, a single tab breaks the whole file.
Raise an issue
Found something which isn't working as it should? Here's how to report it.
- Bug within the app: Open an issue on Abuarchiv/manawg
- Template not working: Open an issue on Abuarchiv/manawg
- This website not working: Open an issue on lissy93/portainer-templates
A Compose stack
ManaWG is a Compose stack, a set of containers (9 of them) defined in one file and brought up together by Portainer, then started and stopped as a single app.
The services
This stack is built from 9 containers that run side by side. Here's each one, with the image it runs and anything it waits for first:
keysrunsghcr.io/abuarchiv/manawg:latestdbrunssupabase/postgres:17.11.0.018, starts after keysauthrunssupabase/gotrue:v2.197.0, starts after dbstoragerunssupabase/storage-api:v1.89.13, starts after db, keysgatewayrunscaddy:2.11.8-alpine, starts after auth, storagesetuprunsghcr.io/abuarchiv/manawg:latest, starts after keys, db, auth, storage, gatewayapprunsghcr.io/abuarchiv/manawg:latest, starts after setupcronrunsghcr.io/abuarchiv/manawg:latest, starts after appmailpitrunsaxllent/mailpit:v1.33.0
Volumes
A volume is where ManaWG keeps its files so they survive an update or a restart. Without one, anything it saves would sit inside the container and vanish the moment it's recreated. This template mounts:
/keyskept in thekeysvolume Docker manages/var/lib/postgresql/datakept in thedb-datavolume Docker manages/etc/postgresql-customkept in thedb-configvolume Docker manages/var/lib/storagekept in thestorage-datavolume Docker manages/srv/mail-templateskept in themail-templatesvolume Docker manages/datakept in thecaddy-datavolume Docker manages/configkept in thecaddy-configvolume Docker manages/app/mail-templateskept in themail-templatesvolume Docker manages
Environment variables
Environment variables are the settings you hand over when you deploy, things like a password or a timezone. ManaWG takes 91 of them, and 5 need a value before it'll start properly:
JWT_SECRET, needs a valuePOSTGRES_PASSWORD, needs a valueMANAWG_APP_DB_PASSWORD, pulled from your own environmentMEMBER_SESSION_SECRET, pulled from your own environmentCRON_SECRET, pulled from your own environmentPOSTGRES_HOST, defaults to/var/run/postgresqlPOSTGRES_PORT, defaults to5432PGPORT, defaults to5432POSTGRES_DB, defaults topostgresPGDATABASE, defaults topostgresPGPASSWORD, pulled from your own environmentGOTRUE_API_HOST, defaults to0.0.0.0GOTRUE_API_PORT, defaults to9999API_EXTERNAL_URL, defaults toAPP_URL/auth/v1GOTRUE_SITE_URL, pulled from your own environmentGOTRUE_URI_ALLOW_LIST, defaults toAPP_URL/**GOTRUE_DB_DRIVER, defaults topostgresGOTRUE_DB_DATABASE_URL, defaults topostgres://supabase_auth_admin:POSTGRES_PASSWORD@db:5432/postgresGOTRUE_DISABLE_SIGNUP, defaults tofalseGOTRUE_JWT_SECRET, pulled from your own environmentGOTRUE_JWT_EXP, defaults to3600GOTRUE_JWT_AUD, defaults toauthenticatedGOTRUE_JWT_ADMIN_ROLES, defaults toservice_roleGOTRUE_JWT_DEFAULT_GROUP_NAME, defaults toauthenticatedGOTRUE_JWT_ISSUER, defaults toAPP_URL/auth/v1GOTRUE_SECURITY_REFRESH_TOKEN_ROTATION_ENABLED, defaults totrueGOTRUE_SECURITY_REFRESH_TOKEN_REUSE_INTERVAL, defaults to10GOTRUE_RATE_LIMIT_HEADER, defaults toX-Forwarded-ForGOTRUE_EXTERNAL_EMAIL_ENABLED, defaults totrueGOTRUE_MAILER_AUTOCONFIRM, defaults tofalseGOTRUE_PASSWORD_MIN_LENGTH, defaults to8GOTRUE_MAILER_SECURE_EMAIL_CHANGE_ENABLED, defaults totrueGOTRUE_MAILER_OTP_LENGTH, defaults to8GOTRUE_MAILER_OTP_EXP, defaults to3600GOTRUE_MAILER_URLPATHS_INVITE, defaults to/auth/v1/verifyGOTRUE_MAILER_URLPATHS_CONFIRMATION, defaults to/auth/v1/verifyGOTRUE_MAILER_URLPATHS_RECOVERY, defaults to/auth/v1/verifyGOTRUE_MAILER_URLPATHS_EMAIL_CHANGE, defaults to/auth/v1/verifyGOTRUE_SMTP_HOST, needs a valueGOTRUE_SMTP_PORT, defaults to587GOTRUE_SMTP_USER, pulled from your own environmentGOTRUE_SMTP_PASS, pulled from your own environmentGOTRUE_SMTP_ADMIN_EMAIL, pulled from your own environmentGOTRUE_SMTP_SENDER_NAME, defaults toManaWGGOTRUE_MAILER_TEMPLATES_CONFIRMATION, defaults tohttp://gateway:8000/mail-templates/confirmation.htmlGOTRUE_MAILER_TEMPLATES_RECOVERY, defaults tohttp://gateway:8000/mail-templates/recovery.htmlGOTRUE_MAILER_TEMPLATES_MAGIC_LINK, defaults tohttp://gateway:8000/mail-templates/magic_link.htmlGOTRUE_MAILER_TEMPLATES_EMAIL_CHANGE, defaults tohttp://gateway:8000/mail-templates/email_change.htmlGOTRUE_MAILER_TEMPLATES_PASSWORD_CHANGED_NOTIFICATION, defaults tohttp://gateway:8000/mail-templates/password_changed.htmlGOTRUE_MAILER_SUBJECTS_CONFIRMATION, defaults to{{ if eq .Data.registration "code" }}{{ if eq .Data.locale "en" }}{{ .Token }} is your code to create the account{{ else if eq .Data.locale "es" }}{{ .Token }} es tu código para crear la cuenta{{ else }}{{ .Token }} ist dein Code zum Erstellen des Kontos{{ end }}{{ else }}{{ if eq .Data.locale "en" }}Confirm your email address{{ else if eq .Data.locale "es" }}Confirma tu dirección de correo{{ else }}Bestätige deine E-Mail-Adresse{{ end }}{{ end }}GOTRUE_MAILER_SUBJECTS_RECOVERY, defaults to{{ if eq .Data.locale "en" }}Set a new password{{ else if eq .Data.locale "es" }}Elige una contraseña nueva{{ else }}Neues Passwort festlegen{{ end }}GOTRUE_MAILER_SUBJECTS_MAGIC_LINK, defaults to{{ if eq .Data.locale "en" }}{{ .Token }} is your sign-in code{{ else if eq .Data.locale "es" }}{{ .Token }} es tu código de inicio de sesión{{ else }}{{ .Token }} ist dein Anmeldecode{{ end }}GOTRUE_MAILER_SUBJECTS_EMAIL_CHANGE, defaults to{{ if eq .Data.locale "en" }}Confirm the change of your email address{{ else if eq .Data.locale "es" }}Confirma el cambio de tu dirección de correo{{ else }}Bestätige die Änderung deiner E-Mail-Adresse{{ end }}GOTRUE_MAILER_SUBJECTS_PASSWORD_CHANGED_NOTIFICATION, defaults to{{ if eq .Data.locale "en" }}Your password was changed{{ else if eq .Data.locale "es" }}Tu contraseña se ha cambiado{{ else }}Dein Passwort wurde geändert{{ end }}GOTRUE_MAILER_NOTIFICATIONS_PASSWORD_CHANGED_ENABLED, defaults totrueGOTRUE_EXTERNAL_GOOGLE_ENABLED, defaults tofalseGOTRUE_EXTERNAL_GOOGLE_CLIENT_ID, pulled from your own environmentGOTRUE_EXTERNAL_GOOGLE_SECRET, pulled from your own environmentGOTRUE_EXTERNAL_GOOGLE_REDIRECT_URI, defaults toAPP_URL/auth/v1/callbackAUTH_JWT_SECRET, pulled from your own environmentDATABASE_URL, defaults topostgres://supabase_storage_admin:POSTGRES_PASSWORD@db:5432/postgresSTORAGE_PUBLIC_URL, pulled from your own environmentREQUEST_ALLOW_X_FORWARDED_PATH, defaults totrueFILE_SIZE_LIMIT, defaults to52428800STORAGE_BACKEND, defaults tofileFILE_STORAGE_BACKEND_PATH, defaults to/var/lib/storageGLOBAL_S3_BUCKET, defaults tostubTENANT_ID, defaults tostubREGION, defaults tostubENABLE_IMAGE_TRANSFORMATION, defaults tofalseSITE_ADDRESS, defaults to:80TRUSTED_PROXIES, defaults to127.0.0.1/32DATABASE_MIGRATION_URL, defaults topostgres://postgres:POSTGRES_PASSWORD@db:5432/postgresSUPABASE_INTERNAL_URL, defaults tohttp://gateway:8000SUPABASE_STORAGE_BUCKET, defaults toprivate-imagesNEXT_PUBLIC_APP_URL, needs a valueNEXT_PUBLIC_LEGAL_URL, pulled from your own environmentMAIL_TEMPLATES_DIR, defaults to/app/mail-templatesNEXT_PUBLIC_SUPABASE_URL, pulled from your own environmentNEXT_PUBLIC_SUPPORT_EMAIL, pulled from your own environmentSMTP_ADMIN_EMAIL, pulled from your own environmentGOOGLE_SIGN_IN, defaults tofalseMEMBER_SESSION_SECRET_PREVIOUS, pulled from your own environmentSMTP_HOST, needs a valueSMTP_PORT, defaults to587SMTP_USER, pulled from your own environmentSMTP_PASS, pulled from your own environmentSTATEMENT_EMAIL_FROM, defaults toManaWG <SMTP_ADMIN_EMAIL>VAPID_PRIVATE_KEY, pulled from your own environmentVAPID_SUBJECT, defaults tomailto:${SMTP_ADMIN_EMAIL}CRON_TARGET_URL, defaults tohttp://app:3000
Restart policy
The restart policy here is unless-stopped, so Docker restarts ManaWG after a crash or reboot, but leaves it off when you stop it on purpose. You can change this on the deploy screen. The choices are no (never restart), on-failure (only after a crash), unless-stopped (restart unless you stop it), and always (bring it back no matter what).
Networking
Portainer puts these services on one shared private network, so they can find each other by name (like keys) while only the ports above are open to you.
Platform
The platform is linux, the kind of system the container is built to run on. Docker and Portainer handle this on a normal Linux server.
Open source license
ManaWG is open source, released under the Apache-2.0 license. In plain terms the code is out in the open, so you're free to run it and change it to fit what you need.
Portainer app templates
Zooming out, this whole page comes from a Portainer app template: a short recipe telling Portainer how to set ManaWG up. Add the template list to Portainer once, then deploying ManaWG is a click rather than a wall of config.