diff --git a/apps/keila/0.30.2/.env.sample b/apps/keila/0.30.2/.env.sample
new file mode 100644
index 000000000..4a20393b2
--- /dev/null
+++ b/apps/keila/0.30.2/.env.sample
@@ -0,0 +1,23 @@
+PANEL_APP_PORT_HTTP=4000
+PANEL_DB_HOST=
+PANEL_DB_PORT=5432
+PANEL_DB_NAME=keila
+PANEL_DB_USER=keila
+PANEL_DB_USER_PASSWORD=
+KEILA_USER=root@localhost
+KEILA_PASSWORD=
+SECRET_KEY_BASE=
+URL_HOST=localhost
+URL_SCHEMA=http
+URL_PORT=4000
+APP_DATA_DIR=./data
+MAILER_SMTP_HOST=
+MAILER_SMTP_PORT=587
+MAILER_SMTP_FROM_EMAIL=
+MAILER_SMTP_USER=
+MAILER_SMTP_PASSWORD=
+MAILER_ENABLE_SSL=false
+MAILER_ENABLE_STARTTLS=false
+LOG_LEVEL=info
+TZ=Asia/Shanghai
+CONTAINER_NAME=
diff --git a/apps/keila/0.30.2/data.yml b/apps/keila/0.30.2/data.yml
new file mode 100644
index 000000000..c50c52713
--- /dev/null
+++ b/apps/keila/0.30.2/data.yml
@@ -0,0 +1,401 @@
+additionalProperties:
+ formFields:
+ - default: 4000
+ edit: true
+ envKey: PANEL_APP_PORT_HTTP
+ labelEn: HTTP Port
+ labelZh: HTTP 端口
+ label:
+ en: HTTP Port
+ zh: HTTP 端口
+ zh-Hant: HTTP 連接埠
+ ja: HTTP ポート
+ ko: HTTP 포트
+ ru: HTTP-порт
+ ms: Port HTTP
+ pt-br: Porta HTTP
+ required: true
+ rule: paramPort
+ type: number
+ - default: postgresql
+ envKey: PANEL_DB_TYPE
+ labelEn: Database Runtime
+ labelZh: 数据库 Runtime
+ label:
+ en: Database Runtime
+ zh: 数据库 Runtime
+ zh-Hant: 資料庫 Runtime
+ ja: データベース Runtime
+ ko: 데이터베이스 Runtime
+ ru: Среда базы данных
+ ms: Runtime Pangkalan Data
+ pt-br: Runtime de banco de dados
+ required: true
+ type: apps
+ values:
+ - label: PostgreSQL
+ value: postgresql
+ child:
+ default: ''
+ envKey: PANEL_DB_HOST
+ labelEn: PostgreSQL Service
+ labelZh: PostgreSQL 服务
+ label:
+ en: PostgreSQL Service
+ zh: PostgreSQL 服务
+ zh-Hant: PostgreSQL 服務
+ ja: PostgreSQL サービス
+ ko: PostgreSQL 서비스
+ ru: Сервис PostgreSQL
+ ms: Perkhidmatan PostgreSQL
+ pt-br: Serviço PostgreSQL
+ required: true
+ type: service
+ - default: 5432
+ edit: true
+ envKey: PANEL_DB_PORT
+ labelEn: Database Port
+ labelZh: 数据库端口
+ label:
+ en: Database Port
+ zh: 数据库端口
+ zh-Hant: 資料庫連接埠
+ ja: データベースポート
+ ko: 데이터베이스 포트
+ ru: Порт базы данных
+ ms: Port Pangkalan Data
+ pt-br: Porta do banco de dados
+ required: true
+ rule: paramPort
+ type: number
+ - default: keila
+ edit: true
+ envKey: PANEL_DB_NAME
+ labelEn: Database Name
+ labelZh: 数据库名
+ label:
+ en: Database Name
+ zh: 数据库名
+ zh-Hant: 資料庫名稱
+ ja: データベース名
+ ko: 데이터베이스 이름
+ ru: Имя базы данных
+ ms: Nama Pangkalan Data
+ pt-br: Nome do banco de dados
+ random: true
+ required: true
+ rule: paramCommon
+ type: text
+ - default: keila
+ edit: true
+ envKey: PANEL_DB_USER
+ labelEn: Database User
+ labelZh: 数据库用户
+ label:
+ en: Database User
+ zh: 数据库用户
+ zh-Hant: 資料庫使用者
+ ja: データベースユーザー
+ ko: 데이터베이스 사용자
+ ru: Пользователь базы данных
+ ms: Pengguna Pangkalan Data
+ pt-br: Usuário do banco de dados
+ random: true
+ required: true
+ rule: paramCommon
+ type: text
+ - default: ''
+ edit: true
+ envKey: PANEL_DB_USER_PASSWORD
+ labelEn: Database Password
+ labelZh: 数据库密码
+ label:
+ en: Database Password
+ zh: 数据库密码
+ zh-Hant: 資料庫密碼
+ ja: データベースパスワード
+ ko: 데이터베이스 비밀번호
+ ru: Пароль базы данных
+ ms: Kata Laluan Pangkalan Data
+ pt-br: Senha do banco de dados
+ required: true
+ rule: paramComplexity
+ type: password
+ - default: root@localhost
+ edit: true
+ envKey: KEILA_USER
+ labelEn: Initial Administrator Email
+ labelZh: 初始管理员邮箱
+ label:
+ en: Initial Administrator Email
+ zh: 初始管理员邮箱
+ zh-Hant: 初始管理員電子郵件
+ ja: 初期管理者メールアドレス
+ ko: 초기 관리자 이메일
+ ru: Email начального администратора
+ ms: E-mel Pentadbir Awal
+ pt-br: E-mail do administrador inicial
+ required: true
+ type: text
+ - default: ''
+ edit: true
+ envKey: KEILA_PASSWORD
+ labelEn: Initial Administrator Password (10+ characters)
+ labelZh: 初始管理员密码(至少 10 个字符)
+ label:
+ en: Initial Administrator Password (10+ characters)
+ zh: 初始管理员密码(至少 10 个字符)
+ zh-Hant: 初始管理員密碼(至少 10 個字元)
+ ja: 初期管理者パスワード(10 文字以上)
+ ko: 초기 관리자 비밀번호(10자 이상)
+ ru: Пароль начального администратора (не менее 10 символов)
+ ms: Kata Laluan Pentadbir Awal (10+ aksara)
+ pt-br: Senha do administrador inicial (10+ caracteres)
+ required: true
+ type: password
+ - default: ''
+ edit: true
+ envKey: SECRET_KEY_BASE
+ labelEn: Session Secret (64+ characters)
+ labelZh: 会话密钥(至少 64 个字符)
+ label:
+ en: Session Secret (64+ characters)
+ zh: 会话密钥(至少 64 个字符)
+ zh-Hant: 工作階段密鑰(至少 64 個字元)
+ ja: セッションシークレット(64 文字以上)
+ ko: 세션 비밀 키(64자 이상)
+ ru: Секрет сеанса (не менее 64 символов)
+ ms: Rahsia Sesi (64+ aksara)
+ pt-br: Segredo da sessão (64+ caracteres)
+ required: true
+ type: password
+ - default: ''
+ edit: true
+ envKey: URL_HOST
+ labelEn: Public Host (domain or IP)
+ labelZh: 公开访问主机(域名或 IP)
+ label:
+ en: Public Host (domain or IP)
+ zh: 公开访问主机(域名或 IP)
+ zh-Hant: 公開存取主機(網域或 IP)
+ ja: 公開ホスト(ドメインまたは IP)
+ ko: 공개 호스트(도메인 또는 IP)
+ ru: Публичный хост (домен или IP)
+ ms: Hos Awam (domain atau IP)
+ pt-br: Host público (domínio ou IP)
+ required: true
+ type: text
+ - default: http
+ edit: true
+ envKey: URL_SCHEMA
+ labelEn: Public URL Scheme
+ labelZh: 公开 URL 协议
+ label:
+ en: Public URL Scheme
+ zh: 公开 URL 协议
+ zh-Hant: 公開 URL 通訊協定
+ ja: 公開 URL スキーム
+ ko: 공개 URL 스키마
+ ru: Схема публичного URL
+ ms: Skema URL Awam
+ pt-br: Esquema da URL pública
+ required: true
+ type: select
+ values:
+ - label: http
+ value: http
+ - label: https
+ value: https
+ - default: 4000
+ edit: true
+ envKey: URL_PORT
+ labelEn: Public URL Port
+ labelZh: 公开 URL 端口
+ label:
+ en: Public URL Port
+ zh: 公开 URL 端口
+ zh-Hant: 公開 URL 連接埠
+ ja: 公開 URL ポート
+ ko: 공개 URL 포트
+ ru: Порт публичного URL
+ ms: Port URL Awam
+ pt-br: Porta da URL pública
+ required: true
+ type: number
+ - default: ./data
+ edit: true
+ envKey: APP_DATA_DIR
+ labelEn: Data Directory
+ labelZh: 数据目录
+ label:
+ en: Data Directory
+ zh: 数据目录
+ zh-Hant: 資料目錄
+ ja: データディレクトリ
+ ko: 데이터 디렉터리
+ ru: Каталог данных
+ ms: Direktori Data
+ pt-br: Diretório de dados
+ required: true
+ type: text
+ - default: ''
+ edit: true
+ envKey: MAILER_SMTP_HOST
+ labelEn: SMTP Host
+ labelZh: SMTP 主机
+ label:
+ en: SMTP Host
+ zh: SMTP 主机
+ zh-Hant: SMTP 主機
+ ja: SMTP ホスト
+ ko: SMTP 호스트
+ ru: SMTP-хост
+ ms: Hos SMTP
+ pt-br: Host SMTP
+ required: true
+ type: text
+ - default: 587
+ edit: true
+ envKey: MAILER_SMTP_PORT
+ labelEn: SMTP Port
+ labelZh: SMTP 端口
+ label:
+ en: SMTP Port
+ zh: SMTP 端口
+ zh-Hant: SMTP 連接埠
+ ja: SMTP ポート
+ ko: SMTP 포트
+ ru: SMTP-порт
+ ms: Port SMTP
+ pt-br: Porta SMTP
+ required: true
+ type: number
+ - default: ''
+ edit: true
+ envKey: MAILER_SMTP_FROM_EMAIL
+ labelEn: SMTP From Email
+ labelZh: SMTP 发件邮箱
+ label:
+ en: SMTP From Email
+ zh: SMTP 发件邮箱
+ zh-Hant: SMTP 寄件者電子郵件
+ ja: SMTP 送信元メールアドレス
+ ko: SMTP 발신 이메일
+ ru: Email отправителя SMTP
+ ms: E-mel Pengirim SMTP
+ pt-br: E-mail do remetente SMTP
+ required: true
+ type: text
+ - default: ''
+ edit: true
+ envKey: MAILER_SMTP_USER
+ labelEn: SMTP Username
+ labelZh: SMTP 用户名
+ label:
+ en: SMTP Username
+ zh: SMTP 用户名
+ zh-Hant: SMTP 使用者名稱
+ ja: SMTP ユーザー名
+ ko: SMTP 사용자 이름
+ ru: Имя пользователя SMTP
+ ms: Nama Pengguna SMTP
+ pt-br: Usuário SMTP
+ required: true
+ type: text
+ - default: ''
+ edit: true
+ envKey: MAILER_SMTP_PASSWORD
+ labelEn: SMTP Password
+ labelZh: SMTP 密码
+ label:
+ en: SMTP Password
+ zh: SMTP 密码
+ zh-Hant: SMTP 密碼
+ ja: SMTP パスワード
+ ko: SMTP 비밀번호
+ ru: Пароль SMTP
+ ms: Kata Laluan SMTP
+ pt-br: Senha SMTP
+ required: true
+ type: password
+ - default: 'false'
+ edit: true
+ envKey: MAILER_ENABLE_SSL
+ labelEn: Enable SMTP SSL/TLS
+ labelZh: 启用 SMTP SSL/TLS
+ label:
+ en: Enable SMTP SSL/TLS
+ zh: 启用 SMTP SSL/TLS
+ zh-Hant: 啟用 SMTP SSL/TLS
+ ja: SMTP SSL/TLS を有効化
+ ko: SMTP SSL/TLS 활성화
+ ru: Включить SMTP SSL/TLS
+ ms: Dayakan SMTP SSL/TLS
+ pt-br: Ativar SMTP SSL/TLS
+ required: true
+ type: select
+ values:
+ - label: 'false'
+ value: 'false'
+ - label: 'true'
+ value: 'true'
+ - default: 'false'
+ edit: true
+ envKey: MAILER_ENABLE_STARTTLS
+ labelEn: Enable SMTP STARTTLS
+ labelZh: 启用 SMTP STARTTLS
+ label:
+ en: Enable SMTP STARTTLS
+ zh: 启用 SMTP STARTTLS
+ zh-Hant: 啟用 SMTP STARTTLS
+ ja: SMTP STARTTLS を有効化
+ ko: SMTP STARTTLS 활성화
+ ru: Включить SMTP STARTTLS
+ ms: Dayakan SMTP STARTTLS
+ pt-br: Ativar SMTP STARTTLS
+ required: true
+ type: select
+ values:
+ - label: 'false'
+ value: 'false'
+ - label: 'true'
+ value: 'true'
+ - default: info
+ edit: true
+ envKey: LOG_LEVEL
+ labelEn: Log Level
+ labelZh: 日志级别
+ label:
+ en: Log Level
+ zh: 日志级别
+ zh-Hant: 日誌層級
+ ja: ログレベル
+ ko: 로그 수준
+ ru: Уровень журналирования
+ ms: Tahap Log
+ pt-br: Nível de log
+ required: true
+ type: select
+ values:
+ - label: debug
+ value: debug
+ - label: info
+ value: info
+ - label: error
+ value: error
+ - default: Asia/Shanghai
+ edit: true
+ envKey: TZ
+ labelEn: Timezone
+ labelZh: 时区
+ label:
+ en: Timezone
+ zh: 时区
+ zh-Hant: 時區
+ ja: タイムゾーン
+ ko: 시간대
+ ru: Часовой пояс
+ ms: Zon Waktu
+ pt-br: Fuso horário
+ required: true
+ type: text
diff --git a/apps/keila/0.30.2/docker-compose.yml b/apps/keila/0.30.2/docker-compose.yml
new file mode 100644
index 000000000..c77c6f9c6
--- /dev/null
+++ b/apps/keila/0.30.2/docker-compose.yml
@@ -0,0 +1,44 @@
+services:
+ keila:
+ image: "pentacent/keila:0.30.2"
+ container_name: ${CONTAINER_NAME}
+ restart: unless-stopped
+ networks:
+ - 1panel-network
+ ports:
+ - "${PANEL_APP_PORT_HTTP}:4000"
+ environment:
+ - DB_URL=postgres://${PANEL_DB_USER}:${PANEL_DB_USER_PASSWORD}@${PANEL_DB_HOST}:${PANEL_DB_PORT}/${PANEL_DB_NAME}
+ - DB_ENABLE_SSL=false
+ - KEILA_USER=${KEILA_USER}
+ - KEILA_PASSWORD=${KEILA_PASSWORD}
+ - SECRET_KEY_BASE=${SECRET_KEY_BASE}
+ - URL_HOST=${URL_HOST}
+ - URL_SCHEMA=${URL_SCHEMA}
+ - URL_PORT=${URL_PORT}
+ - USER_CONTENT_DIR=/opt/app/uploads
+ - MAILER_SMTP_HOST=${MAILER_SMTP_HOST}
+ - MAILER_SMTP_PORT=${MAILER_SMTP_PORT}
+ - MAILER_SMTP_FROM_EMAIL=${MAILER_SMTP_FROM_EMAIL}
+ - MAILER_SMTP_USER=${MAILER_SMTP_USER}
+ - MAILER_SMTP_PASSWORD=${MAILER_SMTP_PASSWORD}
+ - MAILER_ENABLE_SSL=${MAILER_ENABLE_SSL}
+ - MAILER_ENABLE_STARTTLS=${MAILER_ENABLE_STARTTLS}
+ - LOG_LEVEL=${LOG_LEVEL}
+ - TZ=${TZ}
+ volumes:
+ - "${APP_DATA_DIR}/uploads:/opt/app/uploads"
+ healthcheck:
+ test:
+ - CMD-SHELL
+ - wget --quiet --tries=1 --spider http://127.0.0.1:4000/auth/login || exit 1
+ interval: 30s
+ timeout: 10s
+ start_period: 90s
+ retries: 5
+ labels:
+ createdBy: "Apps"
+
+networks:
+ 1panel-network:
+ external: true
diff --git a/apps/keila/0.30.2/scripts/init.sh b/apps/keila/0.30.2/scripts/init.sh
new file mode 100755
index 000000000..116e04e94
--- /dev/null
+++ b/apps/keila/0.30.2/scripts/init.sh
@@ -0,0 +1,111 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)"
+ENV_FILE="${ENV_FILE:-${ROOT_DIR}/.env}"
+
+fail() {
+ printf '%s\n' "$1" >&2
+ exit 1
+}
+
+strip_matching_quotes() {
+ local value="$1"
+
+ if [[ ${#value} -ge 2 ]]; then
+ if [[ "${value:0:1}" == '"' && "${value: -1}" == '"' ]]; then
+ value="${value:1:${#value}-2}"
+ elif [[ "${value:0:1}" == "'" && "${value: -1}" == "'" ]]; then
+ value="${value:1:${#value}-2}"
+ fi
+ fi
+ printf '%s\n' "$value"
+}
+
+read_env_value() {
+ local key="$1"
+ local value=""
+
+ if [[ -f "$ENV_FILE" ]]; then
+ value="$(grep -E "^${key}=" "$ENV_FILE" | tail -n 1 | cut -d '=' -f 2- || true)"
+ fi
+ strip_matching_quotes "$value"
+}
+
+set_env_value() {
+ local key="$1"
+ local value="$2"
+ local temp_file
+
+ temp_file="$(mktemp "${ENV_FILE}.tmp.XXXXXX")"
+ awk -v key="$key" -v value="$value" '
+ BEGIN { updated = 0 }
+ $0 ~ ("^" key "=") {
+ if (!updated) {
+ print key "=" value
+ updated = 1
+ }
+ next
+ }
+ { print }
+ END {
+ if (!updated) print key "=" value
+ }
+ ' "$ENV_FILE" > "$temp_file"
+ chmod --reference="$ENV_FILE" "$temp_file"
+ mv -f -- "$temp_file" "$ENV_FILE"
+}
+
+ensure_secret_key_base() {
+ local secret_key_base
+
+ secret_key_base="$(read_env_value SECRET_KEY_BASE)"
+ if [[ ${#secret_key_base} -lt 64 ]]; then
+ secret_key_base="$(od -An -N64 -tx1 /dev/urandom | tr -d ' \n')"
+ set_env_value SECRET_KEY_BASE "$secret_key_base"
+ fi
+}
+
+path_is_dotenv_safe() {
+ local value="$1"
+
+ case "$value" in
+ *$'\n'* | *$'\r'* | *\\* | *'$'* | *'#'* | *'"'* | *"'"*) return 1 ;;
+ *) return 0 ;;
+ esac
+}
+
+[[ -f "$ENV_FILE" ]] || fail "Environment file not found: ${ENV_FILE}"
+ensure_secret_key_base
+
+if [[ ${APP_DATA_DIR+x} ]]; then
+ APP_DATA_DIR_RAW="$APP_DATA_DIR"
+else
+ APP_DATA_DIR_RAW="$(read_env_value APP_DATA_DIR)"
+fi
+APP_DATA_DIR_RAW="$(strip_matching_quotes "${APP_DATA_DIR_RAW:-./data}")"
+
+[[ -n "$APP_DATA_DIR_RAW" ]] || fail "APP_DATA_DIR must not be empty"
+path_is_dotenv_safe "$APP_DATA_DIR_RAW" || fail "APP_DATA_DIR contains unsupported dotenv characters"
+
+case "$APP_DATA_DIR_RAW" in
+ /*)
+ APP_DATA_DIR_ABS="$(realpath -m -- "$APP_DATA_DIR_RAW")"
+ ;;
+ *)
+ APP_DATA_DIR_ABS="$(realpath -m -- "${ROOT_DIR}/${APP_DATA_DIR_RAW#./}")"
+ case "$APP_DATA_DIR_ABS" in
+ "${ROOT_DIR}" | "${ROOT_DIR}"/*) ;;
+ *) fail "Relative APP_DATA_DIR must stay inside the application directory" ;;
+ esac
+ ;;
+esac
+
+[[ "$APP_DATA_DIR_ABS" != "/" ]] || fail "APP_DATA_DIR must not be the filesystem root"
+[[ ! -L "$APP_DATA_DIR_ABS" ]] || fail "APP_DATA_DIR must not be a symbolic link"
+if [[ -e "$APP_DATA_DIR_ABS" && ! -d "$APP_DATA_DIR_ABS" ]]; then
+ fail "APP_DATA_DIR must be a directory"
+fi
+
+mkdir -p -- "${APP_DATA_DIR_ABS}/uploads"
+chmod 750 -- "$APP_DATA_DIR_ABS" "${APP_DATA_DIR_ABS}/uploads"
diff --git a/apps/keila/0.30.2/scripts/uninstall.sh b/apps/keila/0.30.2/scripts/uninstall.sh
new file mode 100755
index 000000000..5a3f6bd6b
--- /dev/null
+++ b/apps/keila/0.30.2/scripts/uninstall.sh
@@ -0,0 +1,8 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+if command -v docker-compose >/dev/null 2>&1; then
+ docker-compose down --volumes --remove-orphans
+else
+ docker compose down --volumes --remove-orphans
+fi
diff --git a/apps/keila/0.30.2/scripts/upgrade.sh b/apps/keila/0.30.2/scripts/upgrade.sh
new file mode 100755
index 000000000..593b31667
--- /dev/null
+++ b/apps/keila/0.30.2/scripts/upgrade.sh
@@ -0,0 +1,5 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
+exec bash "${SCRIPT_DIR}/init.sh"
diff --git a/apps/keila/README.md b/apps/keila/README.md
new file mode 100644
index 000000000..589ba7d17
--- /dev/null
+++ b/apps/keila/README.md
@@ -0,0 +1,74 @@
+# Keila
+
+## 产品介绍
+
+Keila 是一款开源的自托管新闻简报与邮件营销平台,可用于管理联系人、订阅表单、邮件模板和营销活动,并支持 SMTP、AWS SES、SendGrid、Mailgun 与 Postmark 等发送渠道。
+
+## 主要功能
+
+- 管理联系人、分组、订阅表单、模板和新闻简报活动。
+- 提供可视化、Markdown 和纯文本邮件编辑能力。
+- 支持公开订阅页面、活动统计和 API。
+- 业务数据保存在 PostgreSQL,本地上传文件保存在独立持久化目录。
+
+## 部署拓扑
+
+- 本应用包只运行一个 Keila service,使用上游官方 `pentacent/keila` 镜像。
+- 安装时必须选择同一 1Panel 中已运行的 PostgreSQL Runtime;1Panel 会为 Keila 创建独立数据库和独立用户。
+- 上游示例 Compose 中的 PostgreSQL service 已替换为 1Panel Runtime 联动,不会在应用包中重复部署数据库。
+- SMTP 是生产启动的必填依赖,但属于用户提供的外部服务,不会作为 sidecar 安装。
+
+## 访问说明
+
+默认 Web 端口为 `4000`。安装后访问 `http://服务器地址:安装端口/auth/login`,使用安装表单中的初始管理员邮箱和密码登录。
+
+- `公开访问主机` 只填写域名或 IP,不包含 `http://`、`https://` 或路径。
+- 直接按端口访问时,公开 URL 端口通常与 Web 端口相同。
+- 通过反向代理发布 HTTPS 时,公开 URL 协议应设为 `https`,公开 URL 端口通常设为 `443`;反向代理必须正确转发 WebSocket。
+- 初始管理员只在空数据库第一次启动时创建。后续修改安装参数不会重置已有管理员密码,请在 Keila 中管理账号。
+- 上游要求管理员密码至少 10 个字符;会话密钥应为至少 64 个字符的强随机值。
+
+## SMTP
+
+Keila 在生产模式启动时必须配置系统 SMTP,用于注册、激活和密码重置等邮件。
+
+- SMTP 用户名在上游可回退为发件邮箱;本应用包要求显式填写,以兼容用户名与发件邮箱不同的服务。
+- 端口 `587` 通常配合 STARTTLS,端口 `465` 通常配合 SSL/TLS。具体组合以邮件服务商文档为准。
+- SMTP 密码、数据库密码、管理员密码和会话密钥都会写入 1Panel 生成的运行时 `.env`,不得提交到 Git 或复制到公开日志。
+
+## 数据、备份与升级
+
+- Keila 会在启动时自动执行 PostgreSQL schema migration,并在空数据库中创建初始 root 用户。
+- 联系人、活动、模板、账号和任务队列数据位于所选 PostgreSQL Runtime 的独立数据库中。
+- 用户上传文件位于 `${APP_DATA_DIR}/uploads`,容器内路径为 `/opt/app/uploads`。
+- 升级前必须同时备份 PostgreSQL 数据库和 `${APP_DATA_DIR}/uploads`。升级期间等待数据库 migration 完成后再停止或重启容器。
+- 固定版和 `latest` 使用相同的持久化边界;回滚时应恢复与目标版本匹配的升级前数据库及上传文件备份。
+- 卸载应用不会删除所选 PostgreSQL Runtime 本身;是否删除 Keila 的独立数据库和用户取决于 1Panel 卸载选项。卸载前请完成备份。
+
+## 安全与许可证
+
+- Compose 不启用 privileged、host network、额外 capability、设备映射或 Docker Socket。
+- 建议通过 1Panel 反向代理启用 HTTPS,并限制管理入口的公网暴露范围。
+- Keila 源代码采用 GNU Affero General Public License v3.0 或更高版本。部署、修改或向网络用户提供服务时,请遵守上游许可证义务。
+- 上游 README 说明 Keila 标志不包含在 AGPL 授权中;标志权利归其权利人,本应用包仅将其用于识别对应产品。
+
+## 参考资料
+
+- 官网:
+- 官方安装文档:
+- 官方配置文档:
+- 官方首次启动文档:
+- 源码与官方 Compose:
+
+## Introduction
+
+Keila is an open-source self-hosted newsletter and email marketing platform. This package runs one official Keila container, links it to a selectable PostgreSQL Runtime managed by 1Panel, and persists local uploads separately.
+
+Production startup requires SMTP configuration. Keila automatically runs database migrations and creates the initial root user on an empty database. Back up both PostgreSQL and `${APP_DATA_DIR}/uploads` before upgrades.
+
+## Features
+
+- Contact, segment, subscription-form, template, and newsletter campaign management.
+- Visual, Markdown, and plain-text email editing.
+- PostgreSQL-backed application data and a persistent local upload directory.
+- Explicit initial administrator, public URL, SMTP, and session-secret configuration.
diff --git a/apps/keila/data.yml b/apps/keila/data.yml
new file mode 100644
index 000000000..58b75a554
--- /dev/null
+++ b/apps/keila/data.yml
@@ -0,0 +1,30 @@
+name: Keila
+tags:
+ - Email
+title: 开源的自托管新闻简报与邮件营销平台
+description: 开源的自托管新闻简报与邮件营销平台
+additionalProperties:
+ key: keila
+ name: Keila
+ tags:
+ - Email
+ type: tool
+ website: https://www.keila.io/
+ document: https://www.keila.io/docs/installation
+ github: https://github.com/pentacent/keila
+ shortDescZh: 开源的自托管新闻简报与邮件营销平台
+ shortDescEn: An open-source self-hosted newsletter and email marketing platform
+ crossVersionUpdate: true
+ limit: 0
+ recommend: 0
+ architectures:
+ - amd64
+ description:
+ en: An open-source self-hosted newsletter and email marketing platform
+ zh: 开源的自托管新闻简报与邮件营销平台
+ zh-Hant: 開源的自託管電子報與電子郵件行銷平台
+ ja: オープンソースのセルフホスト型ニュースレター・メールマーケティングプラットフォーム
+ ko: 오픈 소스 자체 호스팅 뉴스레터 및 이메일 마케팅 플랫폼
+ ru: Самостоятельно размещаемая платформа с открытым исходным кодом для рассылок и email-маркетинга
+ ms: Platform sumber terbuka yang dihos sendiri untuk surat berita dan pemasaran e-mel
+ pt-br: Plataforma de código aberto auto-hospedada para newsletters e marketing por e-mail
diff --git a/apps/keila/latest/.env.sample b/apps/keila/latest/.env.sample
new file mode 100644
index 000000000..4a20393b2
--- /dev/null
+++ b/apps/keila/latest/.env.sample
@@ -0,0 +1,23 @@
+PANEL_APP_PORT_HTTP=4000
+PANEL_DB_HOST=
+PANEL_DB_PORT=5432
+PANEL_DB_NAME=keila
+PANEL_DB_USER=keila
+PANEL_DB_USER_PASSWORD=
+KEILA_USER=root@localhost
+KEILA_PASSWORD=
+SECRET_KEY_BASE=
+URL_HOST=localhost
+URL_SCHEMA=http
+URL_PORT=4000
+APP_DATA_DIR=./data
+MAILER_SMTP_HOST=
+MAILER_SMTP_PORT=587
+MAILER_SMTP_FROM_EMAIL=
+MAILER_SMTP_USER=
+MAILER_SMTP_PASSWORD=
+MAILER_ENABLE_SSL=false
+MAILER_ENABLE_STARTTLS=false
+LOG_LEVEL=info
+TZ=Asia/Shanghai
+CONTAINER_NAME=
diff --git a/apps/keila/latest/data.yml b/apps/keila/latest/data.yml
new file mode 100644
index 000000000..c50c52713
--- /dev/null
+++ b/apps/keila/latest/data.yml
@@ -0,0 +1,401 @@
+additionalProperties:
+ formFields:
+ - default: 4000
+ edit: true
+ envKey: PANEL_APP_PORT_HTTP
+ labelEn: HTTP Port
+ labelZh: HTTP 端口
+ label:
+ en: HTTP Port
+ zh: HTTP 端口
+ zh-Hant: HTTP 連接埠
+ ja: HTTP ポート
+ ko: HTTP 포트
+ ru: HTTP-порт
+ ms: Port HTTP
+ pt-br: Porta HTTP
+ required: true
+ rule: paramPort
+ type: number
+ - default: postgresql
+ envKey: PANEL_DB_TYPE
+ labelEn: Database Runtime
+ labelZh: 数据库 Runtime
+ label:
+ en: Database Runtime
+ zh: 数据库 Runtime
+ zh-Hant: 資料庫 Runtime
+ ja: データベース Runtime
+ ko: 데이터베이스 Runtime
+ ru: Среда базы данных
+ ms: Runtime Pangkalan Data
+ pt-br: Runtime de banco de dados
+ required: true
+ type: apps
+ values:
+ - label: PostgreSQL
+ value: postgresql
+ child:
+ default: ''
+ envKey: PANEL_DB_HOST
+ labelEn: PostgreSQL Service
+ labelZh: PostgreSQL 服务
+ label:
+ en: PostgreSQL Service
+ zh: PostgreSQL 服务
+ zh-Hant: PostgreSQL 服務
+ ja: PostgreSQL サービス
+ ko: PostgreSQL 서비스
+ ru: Сервис PostgreSQL
+ ms: Perkhidmatan PostgreSQL
+ pt-br: Serviço PostgreSQL
+ required: true
+ type: service
+ - default: 5432
+ edit: true
+ envKey: PANEL_DB_PORT
+ labelEn: Database Port
+ labelZh: 数据库端口
+ label:
+ en: Database Port
+ zh: 数据库端口
+ zh-Hant: 資料庫連接埠
+ ja: データベースポート
+ ko: 데이터베이스 포트
+ ru: Порт базы данных
+ ms: Port Pangkalan Data
+ pt-br: Porta do banco de dados
+ required: true
+ rule: paramPort
+ type: number
+ - default: keila
+ edit: true
+ envKey: PANEL_DB_NAME
+ labelEn: Database Name
+ labelZh: 数据库名
+ label:
+ en: Database Name
+ zh: 数据库名
+ zh-Hant: 資料庫名稱
+ ja: データベース名
+ ko: 데이터베이스 이름
+ ru: Имя базы данных
+ ms: Nama Pangkalan Data
+ pt-br: Nome do banco de dados
+ random: true
+ required: true
+ rule: paramCommon
+ type: text
+ - default: keila
+ edit: true
+ envKey: PANEL_DB_USER
+ labelEn: Database User
+ labelZh: 数据库用户
+ label:
+ en: Database User
+ zh: 数据库用户
+ zh-Hant: 資料庫使用者
+ ja: データベースユーザー
+ ko: 데이터베이스 사용자
+ ru: Пользователь базы данных
+ ms: Pengguna Pangkalan Data
+ pt-br: Usuário do banco de dados
+ random: true
+ required: true
+ rule: paramCommon
+ type: text
+ - default: ''
+ edit: true
+ envKey: PANEL_DB_USER_PASSWORD
+ labelEn: Database Password
+ labelZh: 数据库密码
+ label:
+ en: Database Password
+ zh: 数据库密码
+ zh-Hant: 資料庫密碼
+ ja: データベースパスワード
+ ko: 데이터베이스 비밀번호
+ ru: Пароль базы данных
+ ms: Kata Laluan Pangkalan Data
+ pt-br: Senha do banco de dados
+ required: true
+ rule: paramComplexity
+ type: password
+ - default: root@localhost
+ edit: true
+ envKey: KEILA_USER
+ labelEn: Initial Administrator Email
+ labelZh: 初始管理员邮箱
+ label:
+ en: Initial Administrator Email
+ zh: 初始管理员邮箱
+ zh-Hant: 初始管理員電子郵件
+ ja: 初期管理者メールアドレス
+ ko: 초기 관리자 이메일
+ ru: Email начального администратора
+ ms: E-mel Pentadbir Awal
+ pt-br: E-mail do administrador inicial
+ required: true
+ type: text
+ - default: ''
+ edit: true
+ envKey: KEILA_PASSWORD
+ labelEn: Initial Administrator Password (10+ characters)
+ labelZh: 初始管理员密码(至少 10 个字符)
+ label:
+ en: Initial Administrator Password (10+ characters)
+ zh: 初始管理员密码(至少 10 个字符)
+ zh-Hant: 初始管理員密碼(至少 10 個字元)
+ ja: 初期管理者パスワード(10 文字以上)
+ ko: 초기 관리자 비밀번호(10자 이상)
+ ru: Пароль начального администратора (не менее 10 символов)
+ ms: Kata Laluan Pentadbir Awal (10+ aksara)
+ pt-br: Senha do administrador inicial (10+ caracteres)
+ required: true
+ type: password
+ - default: ''
+ edit: true
+ envKey: SECRET_KEY_BASE
+ labelEn: Session Secret (64+ characters)
+ labelZh: 会话密钥(至少 64 个字符)
+ label:
+ en: Session Secret (64+ characters)
+ zh: 会话密钥(至少 64 个字符)
+ zh-Hant: 工作階段密鑰(至少 64 個字元)
+ ja: セッションシークレット(64 文字以上)
+ ko: 세션 비밀 키(64자 이상)
+ ru: Секрет сеанса (не менее 64 символов)
+ ms: Rahsia Sesi (64+ aksara)
+ pt-br: Segredo da sessão (64+ caracteres)
+ required: true
+ type: password
+ - default: ''
+ edit: true
+ envKey: URL_HOST
+ labelEn: Public Host (domain or IP)
+ labelZh: 公开访问主机(域名或 IP)
+ label:
+ en: Public Host (domain or IP)
+ zh: 公开访问主机(域名或 IP)
+ zh-Hant: 公開存取主機(網域或 IP)
+ ja: 公開ホスト(ドメインまたは IP)
+ ko: 공개 호스트(도메인 또는 IP)
+ ru: Публичный хост (домен или IP)
+ ms: Hos Awam (domain atau IP)
+ pt-br: Host público (domínio ou IP)
+ required: true
+ type: text
+ - default: http
+ edit: true
+ envKey: URL_SCHEMA
+ labelEn: Public URL Scheme
+ labelZh: 公开 URL 协议
+ label:
+ en: Public URL Scheme
+ zh: 公开 URL 协议
+ zh-Hant: 公開 URL 通訊協定
+ ja: 公開 URL スキーム
+ ko: 공개 URL 스키마
+ ru: Схема публичного URL
+ ms: Skema URL Awam
+ pt-br: Esquema da URL pública
+ required: true
+ type: select
+ values:
+ - label: http
+ value: http
+ - label: https
+ value: https
+ - default: 4000
+ edit: true
+ envKey: URL_PORT
+ labelEn: Public URL Port
+ labelZh: 公开 URL 端口
+ label:
+ en: Public URL Port
+ zh: 公开 URL 端口
+ zh-Hant: 公開 URL 連接埠
+ ja: 公開 URL ポート
+ ko: 공개 URL 포트
+ ru: Порт публичного URL
+ ms: Port URL Awam
+ pt-br: Porta da URL pública
+ required: true
+ type: number
+ - default: ./data
+ edit: true
+ envKey: APP_DATA_DIR
+ labelEn: Data Directory
+ labelZh: 数据目录
+ label:
+ en: Data Directory
+ zh: 数据目录
+ zh-Hant: 資料目錄
+ ja: データディレクトリ
+ ko: 데이터 디렉터리
+ ru: Каталог данных
+ ms: Direktori Data
+ pt-br: Diretório de dados
+ required: true
+ type: text
+ - default: ''
+ edit: true
+ envKey: MAILER_SMTP_HOST
+ labelEn: SMTP Host
+ labelZh: SMTP 主机
+ label:
+ en: SMTP Host
+ zh: SMTP 主机
+ zh-Hant: SMTP 主機
+ ja: SMTP ホスト
+ ko: SMTP 호스트
+ ru: SMTP-хост
+ ms: Hos SMTP
+ pt-br: Host SMTP
+ required: true
+ type: text
+ - default: 587
+ edit: true
+ envKey: MAILER_SMTP_PORT
+ labelEn: SMTP Port
+ labelZh: SMTP 端口
+ label:
+ en: SMTP Port
+ zh: SMTP 端口
+ zh-Hant: SMTP 連接埠
+ ja: SMTP ポート
+ ko: SMTP 포트
+ ru: SMTP-порт
+ ms: Port SMTP
+ pt-br: Porta SMTP
+ required: true
+ type: number
+ - default: ''
+ edit: true
+ envKey: MAILER_SMTP_FROM_EMAIL
+ labelEn: SMTP From Email
+ labelZh: SMTP 发件邮箱
+ label:
+ en: SMTP From Email
+ zh: SMTP 发件邮箱
+ zh-Hant: SMTP 寄件者電子郵件
+ ja: SMTP 送信元メールアドレス
+ ko: SMTP 발신 이메일
+ ru: Email отправителя SMTP
+ ms: E-mel Pengirim SMTP
+ pt-br: E-mail do remetente SMTP
+ required: true
+ type: text
+ - default: ''
+ edit: true
+ envKey: MAILER_SMTP_USER
+ labelEn: SMTP Username
+ labelZh: SMTP 用户名
+ label:
+ en: SMTP Username
+ zh: SMTP 用户名
+ zh-Hant: SMTP 使用者名稱
+ ja: SMTP ユーザー名
+ ko: SMTP 사용자 이름
+ ru: Имя пользователя SMTP
+ ms: Nama Pengguna SMTP
+ pt-br: Usuário SMTP
+ required: true
+ type: text
+ - default: ''
+ edit: true
+ envKey: MAILER_SMTP_PASSWORD
+ labelEn: SMTP Password
+ labelZh: SMTP 密码
+ label:
+ en: SMTP Password
+ zh: SMTP 密码
+ zh-Hant: SMTP 密碼
+ ja: SMTP パスワード
+ ko: SMTP 비밀번호
+ ru: Пароль SMTP
+ ms: Kata Laluan SMTP
+ pt-br: Senha SMTP
+ required: true
+ type: password
+ - default: 'false'
+ edit: true
+ envKey: MAILER_ENABLE_SSL
+ labelEn: Enable SMTP SSL/TLS
+ labelZh: 启用 SMTP SSL/TLS
+ label:
+ en: Enable SMTP SSL/TLS
+ zh: 启用 SMTP SSL/TLS
+ zh-Hant: 啟用 SMTP SSL/TLS
+ ja: SMTP SSL/TLS を有効化
+ ko: SMTP SSL/TLS 활성화
+ ru: Включить SMTP SSL/TLS
+ ms: Dayakan SMTP SSL/TLS
+ pt-br: Ativar SMTP SSL/TLS
+ required: true
+ type: select
+ values:
+ - label: 'false'
+ value: 'false'
+ - label: 'true'
+ value: 'true'
+ - default: 'false'
+ edit: true
+ envKey: MAILER_ENABLE_STARTTLS
+ labelEn: Enable SMTP STARTTLS
+ labelZh: 启用 SMTP STARTTLS
+ label:
+ en: Enable SMTP STARTTLS
+ zh: 启用 SMTP STARTTLS
+ zh-Hant: 啟用 SMTP STARTTLS
+ ja: SMTP STARTTLS を有効化
+ ko: SMTP STARTTLS 활성화
+ ru: Включить SMTP STARTTLS
+ ms: Dayakan SMTP STARTTLS
+ pt-br: Ativar SMTP STARTTLS
+ required: true
+ type: select
+ values:
+ - label: 'false'
+ value: 'false'
+ - label: 'true'
+ value: 'true'
+ - default: info
+ edit: true
+ envKey: LOG_LEVEL
+ labelEn: Log Level
+ labelZh: 日志级别
+ label:
+ en: Log Level
+ zh: 日志级别
+ zh-Hant: 日誌層級
+ ja: ログレベル
+ ko: 로그 수준
+ ru: Уровень журналирования
+ ms: Tahap Log
+ pt-br: Nível de log
+ required: true
+ type: select
+ values:
+ - label: debug
+ value: debug
+ - label: info
+ value: info
+ - label: error
+ value: error
+ - default: Asia/Shanghai
+ edit: true
+ envKey: TZ
+ labelEn: Timezone
+ labelZh: 时区
+ label:
+ en: Timezone
+ zh: 时区
+ zh-Hant: 時區
+ ja: タイムゾーン
+ ko: 시간대
+ ru: Часовой пояс
+ ms: Zon Waktu
+ pt-br: Fuso horário
+ required: true
+ type: text
diff --git a/apps/keila/latest/docker-compose.yml b/apps/keila/latest/docker-compose.yml
new file mode 100644
index 000000000..6da76e933
--- /dev/null
+++ b/apps/keila/latest/docker-compose.yml
@@ -0,0 +1,44 @@
+services:
+ keila:
+ image: "pentacent/keila:latest"
+ container_name: ${CONTAINER_NAME}
+ restart: unless-stopped
+ networks:
+ - 1panel-network
+ ports:
+ - "${PANEL_APP_PORT_HTTP}:4000"
+ environment:
+ - DB_URL=postgres://${PANEL_DB_USER}:${PANEL_DB_USER_PASSWORD}@${PANEL_DB_HOST}:${PANEL_DB_PORT}/${PANEL_DB_NAME}
+ - DB_ENABLE_SSL=false
+ - KEILA_USER=${KEILA_USER}
+ - KEILA_PASSWORD=${KEILA_PASSWORD}
+ - SECRET_KEY_BASE=${SECRET_KEY_BASE}
+ - URL_HOST=${URL_HOST}
+ - URL_SCHEMA=${URL_SCHEMA}
+ - URL_PORT=${URL_PORT}
+ - USER_CONTENT_DIR=/opt/app/uploads
+ - MAILER_SMTP_HOST=${MAILER_SMTP_HOST}
+ - MAILER_SMTP_PORT=${MAILER_SMTP_PORT}
+ - MAILER_SMTP_FROM_EMAIL=${MAILER_SMTP_FROM_EMAIL}
+ - MAILER_SMTP_USER=${MAILER_SMTP_USER}
+ - MAILER_SMTP_PASSWORD=${MAILER_SMTP_PASSWORD}
+ - MAILER_ENABLE_SSL=${MAILER_ENABLE_SSL}
+ - MAILER_ENABLE_STARTTLS=${MAILER_ENABLE_STARTTLS}
+ - LOG_LEVEL=${LOG_LEVEL}
+ - TZ=${TZ}
+ volumes:
+ - "${APP_DATA_DIR}/uploads:/opt/app/uploads"
+ healthcheck:
+ test:
+ - CMD-SHELL
+ - wget --quiet --tries=1 --spider http://127.0.0.1:4000/auth/login || exit 1
+ interval: 30s
+ timeout: 10s
+ start_period: 90s
+ retries: 5
+ labels:
+ createdBy: "Apps"
+
+networks:
+ 1panel-network:
+ external: true
diff --git a/apps/keila/latest/scripts/init.sh b/apps/keila/latest/scripts/init.sh
new file mode 100755
index 000000000..116e04e94
--- /dev/null
+++ b/apps/keila/latest/scripts/init.sh
@@ -0,0 +1,111 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)"
+ENV_FILE="${ENV_FILE:-${ROOT_DIR}/.env}"
+
+fail() {
+ printf '%s\n' "$1" >&2
+ exit 1
+}
+
+strip_matching_quotes() {
+ local value="$1"
+
+ if [[ ${#value} -ge 2 ]]; then
+ if [[ "${value:0:1}" == '"' && "${value: -1}" == '"' ]]; then
+ value="${value:1:${#value}-2}"
+ elif [[ "${value:0:1}" == "'" && "${value: -1}" == "'" ]]; then
+ value="${value:1:${#value}-2}"
+ fi
+ fi
+ printf '%s\n' "$value"
+}
+
+read_env_value() {
+ local key="$1"
+ local value=""
+
+ if [[ -f "$ENV_FILE" ]]; then
+ value="$(grep -E "^${key}=" "$ENV_FILE" | tail -n 1 | cut -d '=' -f 2- || true)"
+ fi
+ strip_matching_quotes "$value"
+}
+
+set_env_value() {
+ local key="$1"
+ local value="$2"
+ local temp_file
+
+ temp_file="$(mktemp "${ENV_FILE}.tmp.XXXXXX")"
+ awk -v key="$key" -v value="$value" '
+ BEGIN { updated = 0 }
+ $0 ~ ("^" key "=") {
+ if (!updated) {
+ print key "=" value
+ updated = 1
+ }
+ next
+ }
+ { print }
+ END {
+ if (!updated) print key "=" value
+ }
+ ' "$ENV_FILE" > "$temp_file"
+ chmod --reference="$ENV_FILE" "$temp_file"
+ mv -f -- "$temp_file" "$ENV_FILE"
+}
+
+ensure_secret_key_base() {
+ local secret_key_base
+
+ secret_key_base="$(read_env_value SECRET_KEY_BASE)"
+ if [[ ${#secret_key_base} -lt 64 ]]; then
+ secret_key_base="$(od -An -N64 -tx1 /dev/urandom | tr -d ' \n')"
+ set_env_value SECRET_KEY_BASE "$secret_key_base"
+ fi
+}
+
+path_is_dotenv_safe() {
+ local value="$1"
+
+ case "$value" in
+ *$'\n'* | *$'\r'* | *\\* | *'$'* | *'#'* | *'"'* | *"'"*) return 1 ;;
+ *) return 0 ;;
+ esac
+}
+
+[[ -f "$ENV_FILE" ]] || fail "Environment file not found: ${ENV_FILE}"
+ensure_secret_key_base
+
+if [[ ${APP_DATA_DIR+x} ]]; then
+ APP_DATA_DIR_RAW="$APP_DATA_DIR"
+else
+ APP_DATA_DIR_RAW="$(read_env_value APP_DATA_DIR)"
+fi
+APP_DATA_DIR_RAW="$(strip_matching_quotes "${APP_DATA_DIR_RAW:-./data}")"
+
+[[ -n "$APP_DATA_DIR_RAW" ]] || fail "APP_DATA_DIR must not be empty"
+path_is_dotenv_safe "$APP_DATA_DIR_RAW" || fail "APP_DATA_DIR contains unsupported dotenv characters"
+
+case "$APP_DATA_DIR_RAW" in
+ /*)
+ APP_DATA_DIR_ABS="$(realpath -m -- "$APP_DATA_DIR_RAW")"
+ ;;
+ *)
+ APP_DATA_DIR_ABS="$(realpath -m -- "${ROOT_DIR}/${APP_DATA_DIR_RAW#./}")"
+ case "$APP_DATA_DIR_ABS" in
+ "${ROOT_DIR}" | "${ROOT_DIR}"/*) ;;
+ *) fail "Relative APP_DATA_DIR must stay inside the application directory" ;;
+ esac
+ ;;
+esac
+
+[[ "$APP_DATA_DIR_ABS" != "/" ]] || fail "APP_DATA_DIR must not be the filesystem root"
+[[ ! -L "$APP_DATA_DIR_ABS" ]] || fail "APP_DATA_DIR must not be a symbolic link"
+if [[ -e "$APP_DATA_DIR_ABS" && ! -d "$APP_DATA_DIR_ABS" ]]; then
+ fail "APP_DATA_DIR must be a directory"
+fi
+
+mkdir -p -- "${APP_DATA_DIR_ABS}/uploads"
+chmod 750 -- "$APP_DATA_DIR_ABS" "${APP_DATA_DIR_ABS}/uploads"
diff --git a/apps/keila/latest/scripts/uninstall.sh b/apps/keila/latest/scripts/uninstall.sh
new file mode 100755
index 000000000..5a3f6bd6b
--- /dev/null
+++ b/apps/keila/latest/scripts/uninstall.sh
@@ -0,0 +1,8 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+if command -v docker-compose >/dev/null 2>&1; then
+ docker-compose down --volumes --remove-orphans
+else
+ docker compose down --volumes --remove-orphans
+fi
diff --git a/apps/keila/latest/scripts/upgrade.sh b/apps/keila/latest/scripts/upgrade.sh
new file mode 100755
index 000000000..593b31667
--- /dev/null
+++ b/apps/keila/latest/scripts/upgrade.sh
@@ -0,0 +1,5 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
+exec bash "${SCRIPT_DIR}/init.sh"
diff --git a/apps/keila/logo.png b/apps/keila/logo.png
new file mode 100644
index 000000000..a50106d4d
Binary files /dev/null and b/apps/keila/logo.png differ