Portainer Templates logo

Portainer Templates

ManaWG ManaWG

Stack

ProductivityHouseholdSelf Hosted

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

Stars: 1
Forks: 1
Language: TypeScript
License: Apache-2.0
Updated: 15 hours ago
Website: manawg.com/

Configuration

Type
Compose
Platform
linux
Env vars
APP_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=
Restart
unless-stopped
Source

Notes

Needs an SMTP account for sign-in codes (or the Mailpit test profile). POSTGRES_PASSWORD: letters and digits only, from 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}
Command
node,/keys.mjs
Volumes
/keys : keys
Env vars
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:-}
Restart
no

db

Configuration

Image
supabase/postgres:17.11.0.018
Command
postgres,-c,config_file=/etc/postgresql/postgresql.conf,-c,log_min_messages=fatal
Volumes
/var/lib/postgresql/data : db-data/etc/postgresql-custom : db-config
Env vars
POSTGRES_HOST=/var/run/postgresqlPOSTGRES_PORT=5432PGPORT=5432POSTGRES_DB=postgresPGDATABASE=postgresPOSTGRES_PASSWORD=${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}PGPASSWORD=${POSTGRES_PASSWORD}
Restart
unless-stopped

Image details

Pulls: 22.9M
User: supabase
Created: Apr 30, 2020
Updated: 7 days ago
Status: active

auth

Configuration

Image
supabase/gotrue:v2.197.0
Env vars
GOTRUE_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/callback
Restart
unless-stopped

Image details

Pulls: 14.5M
User: supabase
Created: Jul 14, 2020
Updated: 1 hour ago
Status: active

storage

Configuration

Image
supabase/storage-api:v1.89.13
Command
node,dist/start/server.js
Entrypoint
sh,-c,. /keys/storage.env && exec docker-entrypoint.sh "$$@",sh
Volumes
/var/lib/storage : storage-data/keys : keys
Env vars
AUTH_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=false
Restart
unless-stopped

Image details

Pulls: 11.0M
User: awalias
Created: Mar 16, 2021
Updated: 2 hours ago
Status: active

gateway

Configuration

Image
caddy:2.11.8-alpine
Ports
${HTTP_BIND:-}${HTTP_PORT:-80}:80${HTTP_BIND:-}${HTTPS_PORT:-443}:443${HTTP_BIND:-}${HTTPS_PORT:-443}:443/udp
Volumes
/srv/mail-templates : mail-templates/data : caddy-data/config : caddy-config
Env vars
SITE_ADDRESS=${SITE_ADDRESS:-:80}TRUSTED_PROXIES=${TRUSTED_PROXIES:-127.0.0.1/32}
Restart
unless-stopped

Image details

Pulls: 765.5M
User: library
Created: Apr 14, 2020
Updated: 4 days ago
Status: active

setup

Configuration

Image
${MANAWG_IMAGE:-ghcr.io/abuarchiv/manawg}:${MANAWG_VERSION:-latest}
Command
sh,-c,. /keys/setup.env && exec node scripts/selfhost/setup.mjs
Volumes
/app/mail-templates : mail-templates/keys : keys
Env vars
DATABASE_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-templates
Restart
no

app

Configuration

Image
${MANAWG_IMAGE:-ghcr.io/abuarchiv/manawg}:${MANAWG_VERSION:-latest}
Command
sh,-c,. /keys/app.env && node scripts/selfhost/public-env.mjs && exec node server.js
Volumes
/keys : keys
Env vars
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: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}}
Restart
unless-stopped

cron

Configuration

Image
${MANAWG_IMAGE:-ghcr.io/abuarchiv/manawg}:${MANAWG_VERSION:-latest}
Command
sh,-c,. /keys/cron.env && exec node scripts/selfhost/cron.mjs
Volumes
/keys : keys
Env vars
CRON_TARGET_URL=http://app:3000
Restart
unless-stopped

mailpit

Configuration

Image
axllent/mailpit:v1.33.0
Ports
127.0.0.1:${MAILPIT_PORT:-8025}:8025
Restart
unless-stopped

Image details

Pulls: 241.0M
User: axllent
Created: Aug 04, 2022
Updated: 11 hours ago
Status: active

Standalone Install

Select an install method, to see config/commands for deploying ManaWG

Installation method

Install on Portainer

Import all app templates into your Portainer instance, for easy 1-click deploys

  1. Ensure both Docker and Portainer are installed, and up-to-date
  2. Log into your Portainer web UI
  3. Under Settings → App Templates, paste the below URL
  4. Head to Home → App Templates, and the list of apps will show up
  5. 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 demo
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 -d

More 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: 137 means killed, usually out of memory. 126 or 127 means 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 login to 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 no so 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.

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:

  • keys runs ghcr.io/abuarchiv/manawg:latest
  • db runs supabase/postgres:17.11.0.018, starts after keys
  • auth runs supabase/gotrue:v2.197.0, starts after db
  • storage runs supabase/storage-api:v1.89.13, starts after db, keys
  • gateway runs caddy:2.11.8-alpine, starts after auth, storage
  • setup runs ghcr.io/abuarchiv/manawg:latest, starts after keys, db, auth, storage, gateway
  • app runs ghcr.io/abuarchiv/manawg:latest, starts after setup
  • cron runs ghcr.io/abuarchiv/manawg:latest, starts after app
  • mailpit runs axllent/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:

  • /keys kept in the keys volume Docker manages
  • /var/lib/postgresql/data kept in the db-data volume Docker manages
  • /etc/postgresql-custom kept in the db-config volume Docker manages
  • /var/lib/storage kept in the storage-data volume Docker manages
  • /srv/mail-templates kept in the mail-templates volume Docker manages
  • /data kept in the caddy-data volume Docker manages
  • /config kept in the caddy-config volume Docker manages
  • /app/mail-templates kept in the mail-templates volume 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 value
  • POSTGRES_PASSWORD, needs a value
  • MANAWG_APP_DB_PASSWORD, pulled from your own environment
  • MEMBER_SESSION_SECRET, pulled from your own environment
  • CRON_SECRET, pulled from your own environment
  • POSTGRES_HOST, defaults to /var/run/postgresql
  • POSTGRES_PORT, defaults to 5432
  • PGPORT, defaults to 5432
  • POSTGRES_DB, defaults to postgres
  • PGDATABASE, defaults to postgres
  • PGPASSWORD, pulled from your own environment
  • GOTRUE_API_HOST, defaults to 0.0.0.0
  • GOTRUE_API_PORT, defaults to 9999
  • API_EXTERNAL_URL, defaults to APP_URL/auth/v1
  • GOTRUE_SITE_URL, pulled from your own environment
  • GOTRUE_URI_ALLOW_LIST, defaults to APP_URL/**
  • GOTRUE_DB_DRIVER, defaults to postgres
  • GOTRUE_DB_DATABASE_URL, defaults to postgres://supabase_auth_admin:POSTGRES_PASSWORD@db:5432/postgres
  • GOTRUE_DISABLE_SIGNUP, defaults to false
  • GOTRUE_JWT_SECRET, pulled from your own environment
  • GOTRUE_JWT_EXP, defaults to 3600
  • GOTRUE_JWT_AUD, defaults to authenticated
  • GOTRUE_JWT_ADMIN_ROLES, defaults to service_role
  • GOTRUE_JWT_DEFAULT_GROUP_NAME, defaults to authenticated
  • GOTRUE_JWT_ISSUER, defaults to APP_URL/auth/v1
  • GOTRUE_SECURITY_REFRESH_TOKEN_ROTATION_ENABLED, defaults to true
  • GOTRUE_SECURITY_REFRESH_TOKEN_REUSE_INTERVAL, defaults to 10
  • GOTRUE_RATE_LIMIT_HEADER, defaults to X-Forwarded-For
  • GOTRUE_EXTERNAL_EMAIL_ENABLED, defaults to true
  • GOTRUE_MAILER_AUTOCONFIRM, defaults to false
  • GOTRUE_PASSWORD_MIN_LENGTH, defaults to 8
  • GOTRUE_MAILER_SECURE_EMAIL_CHANGE_ENABLED, defaults to true
  • GOTRUE_MAILER_OTP_LENGTH, defaults to 8
  • GOTRUE_MAILER_OTP_EXP, defaults to 3600
  • GOTRUE_MAILER_URLPATHS_INVITE, defaults to /auth/v1/verify
  • GOTRUE_MAILER_URLPATHS_CONFIRMATION, defaults to /auth/v1/verify
  • GOTRUE_MAILER_URLPATHS_RECOVERY, defaults to /auth/v1/verify
  • GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE, defaults to /auth/v1/verify
  • GOTRUE_SMTP_HOST, needs a value
  • GOTRUE_SMTP_PORT, defaults to 587
  • GOTRUE_SMTP_USER, pulled from your own environment
  • GOTRUE_SMTP_PASS, pulled from your own environment
  • GOTRUE_SMTP_ADMIN_EMAIL, pulled from your own environment
  • GOTRUE_SMTP_SENDER_NAME, defaults to ManaWG
  • GOTRUE_MAILER_TEMPLATES_CONFIRMATION, defaults to http://gateway:8000/mail-templates/confirmation.html
  • GOTRUE_MAILER_TEMPLATES_RECOVERY, defaults to http://gateway:8000/mail-templates/recovery.html
  • GOTRUE_MAILER_TEMPLATES_MAGIC_LINK, defaults to http://gateway:8000/mail-templates/magic_link.html
  • GOTRUE_MAILER_TEMPLATES_EMAIL_CHANGE, defaults to http://gateway:8000/mail-templates/email_change.html
  • GOTRUE_MAILER_TEMPLATES_PASSWORD_CHANGED_NOTIFICATION, defaults to http://gateway:8000/mail-templates/password_changed.html
  • GOTRUE_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 to true
  • GOTRUE_EXTERNAL_GOOGLE_ENABLED, defaults to false
  • GOTRUE_EXTERNAL_GOOGLE_CLIENT_ID, pulled from your own environment
  • GOTRUE_EXTERNAL_GOOGLE_SECRET, pulled from your own environment
  • GOTRUE_EXTERNAL_GOOGLE_REDIRECT_URI, defaults to APP_URL/auth/v1/callback
  • AUTH_JWT_SECRET, pulled from your own environment
  • DATABASE_URL, defaults to postgres://supabase_storage_admin:POSTGRES_PASSWORD@db:5432/postgres
  • STORAGE_PUBLIC_URL, pulled from your own environment
  • REQUEST_ALLOW_X_FORWARDED_PATH, defaults to true
  • FILE_SIZE_LIMIT, defaults to 52428800
  • STORAGE_BACKEND, defaults to file
  • FILE_STORAGE_BACKEND_PATH, defaults to /var/lib/storage
  • GLOBAL_S3_BUCKET, defaults to stub
  • TENANT_ID, defaults to stub
  • REGION, defaults to stub
  • ENABLE_IMAGE_TRANSFORMATION, defaults to false
  • SITE_ADDRESS, defaults to :80
  • TRUSTED_PROXIES, defaults to 127.0.0.1/32
  • DATABASE_MIGRATION_URL, defaults to postgres://postgres:POSTGRES_PASSWORD@db:5432/postgres
  • SUPABASE_INTERNAL_URL, defaults to http://gateway:8000
  • SUPABASE_STORAGE_BUCKET, defaults to private-images
  • NEXT_PUBLIC_APP_URL, needs a value
  • NEXT_PUBLIC_LEGAL_URL, pulled from your own environment
  • MAIL_TEMPLATES_DIR, defaults to /app/mail-templates
  • NEXT_PUBLIC_SUPABASE_URL, pulled from your own environment
  • NEXT_PUBLIC_SUPPORT_EMAIL, pulled from your own environment
  • SMTP_ADMIN_EMAIL, pulled from your own environment
  • GOOGLE_SIGN_IN, defaults to false
  • MEMBER_SESSION_SECRET_PREVIOUS, pulled from your own environment
  • SMTP_HOST, needs a value
  • SMTP_PORT, defaults to 587
  • SMTP_USER, pulled from your own environment
  • SMTP_PASS, pulled from your own environment
  • STATEMENT_EMAIL_FROM, defaults to ManaWG <SMTP_ADMIN_EMAIL>
  • VAPID_PRIVATE_KEY, pulled from your own environment
  • VAPID_SUBJECT, defaults to mailto:${SMTP_ADMIN_EMAIL}
  • CRON_TARGET_URL, defaults to http://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.