CLAUDE.md Dockerfile .env.scripts .gitattributes .gitea/workflows/docker.yaml .gitignore LICENSE.md PLAN.md README.md rootfs/root/docker/setup/04-users.sh rootfs/root/docker/setup/05-custom.sh rootfs/tmp/ rootfs/usr/local/bin/entrypoint.sh rootfs/usr/local/bin/pkmgr rootfs/usr/local/etc/docker/bin/ rootfs/usr/local/etc/docker/init.d/99-apprise.sh rootfs/usr/local/etc/docker/init.d/zz-default.sh
15 KiB
apprise migration plan
Service intent
A self-hosted REST notification gateway built on the upstream caronc/apprise-api Django web app. Runs as a single Alpine-based Docker image bundling nginx + gunicorn + Django + apprise so users can POST a notification (with one or more notification URLs) and have it delivered to dozens of services (Discord, Slack, Telegram, Pushover, email, MQTT, etc.). Persistent stateful configs (/cfg/<key> style YAML/text profiles) live under /config/store. Container exposes :8000. Volumes: /config (apprise YAML/text config files + the canonical store), /data (logs, attachments, temp). Optional volumes the upstream supports (mapped under /config in our layout instead of separate mounts): attachments and plugin paths.
Service stack
- Web frontend:
nginx(Alpine), main config served from/etc/nginx/nginx.conf-> proxies*to gunicorn overunix:/run/apprise/gunicorn.sock. We base our nginx.conf on the upstreamapprise-api/etc/nginx.conf(route table for/,/notify,/notify/<key>,/status,/metrics,/details,/cfg,/add,/del,/get,/json/urls/...,/_/,/s/,/favicon.ico,/robots.txt, catch-all). Singleserver { listen 8000; }block. - Application:
apprise-apiDjango app cloned from upstreamgithub.com/caronc/apprise-apito/usr/local/share/apprise-api/webapp/(matches upstream's/opt/apprise/webapplayout but in our/usr/local/share/<app>/convention). Run via gunicorn. - WSGI server:
gunicornwithgeventworker class — invoked asgunicorn -c /usr/local/share/apprise-api/webapp/gunicorn.conf.py --worker-tmp-dir /dev/shm core.wsgi. Listens onunix:/run/apprise/gunicorn.sock. - Notification engine:
apprise(Python lib,pip install apprise). Pulls in 80+ notification backends (the rest of the upstreamrequirements.txtsuch aspaho-mqtt,gntp,cryptography,PGPy,slixmpp,smpplib— Alpine has packages for some, the rest come from pip). - Process supervisor: a small shell script
start-appriseat/usr/local/etc/docker/bin/start-apprise(mirrors ampache'sstart-ampachepattern) that starts gunicorn in the background, waits for the unix socket, thenexecs nginx in the foreground. The framework's99-apprise.shinvokes this single binary as itsEXEC_CMD_BIN— the framework already handles supervision/restart.
Packages (PACK_LIST / ENV_PACKAGES)
Verified against pkgs.alpinelinux.org for the edge branch (community + main). Each entry has a one-line justification.
System glue:
bash— entrypoint and 99-* scripts are bash.tini— PID 1 init.curl,wget— entrypoint healthcheck + cloning fallback.git— clone the apprise-api repo at build time.tzdata— TZ awareness in nginx and Python logging.ca-certificates— TLS to outbound notification services.pwgen— random secrets/seed material if needed.tar,gzip— unpack archives if any (defensive).
nginx:
nginx— front HTTP(S) proxy (port 8000 inside the container).nginx-mod-http-headers-more— not strictly required, dropped to keep image lean. Defaultnginxshipsmod_http_realipetc., which is enough for our X-Forwarded-* setup.
Python runtime + Alpine-packaged Python deps (saves a lot of pip compile time and avoids needing build toolchain):
python3— Python 3.x runtime.py3-pip— pip for the leftover deps from upstreamrequirements.txt.py3-setuptools,py3-wheel— needed for pip installs.py3-django— Django web framework (5.x in Alpine edge; upstream pins toDjangoopen).py3-gunicorn— WSGI HTTP server.py3-gevent— async worker class for gunicorn (upstream usesworker_class = "gevent").py3-cryptography— X.509/key handling; required by apprise + PGPy.py3-requests— HTTP client.py3-yaml— YAML config parsing (apprise YAML configs).py3-paho-mqtt— MQTT notification backend.py3-aiodns— async DNS, used by gevent.py3-prometheus-client—/metricsendpoint support.py3-charset-normalizer— requests dep, packaged.py3-markdown— apprise UI markdown rendering.py3-six— packaged transitive dep.py3-django-prometheus— Django prometheus middleware (upstream requirement).py3-zope-event,py3-zope-interface— gevent transitive deps (avoid pip rebuild).
Dropped from a hypothetical kitchen-sink list: py3-uwsgi, uwsgi, uwsgi-python3 — upstream switched to gunicorn long ago; we follow upstream. No apache2/php-fpm/mariadb either; this is a pure Python web service.
pip install (in 02-packages.sh, with --break-system-packages) for the deps Alpine doesn't ship:
apprise(the notification library itself; the apprise-api Django app imports it at runtime).PGPy(PGP message support; not in Alpine).slixmpp >= 1.10.0(XMPP; not in Alpine).smpplib(SMPP; not in Alpine).gntp(Growl; not in Alpine).
Configs to ship in rootfs/tmp/etc/
Wipe-and-replace at build time (per template §4). All paths under rootfs/tmp/etc/.
nginx/nginx.conf— based on upstreamapprise-api/webapp/etc/nginx.conf, lifted into our standard structure. Top-level:daemon off;,worker_processes auto;,pid /run/apprise/nginx.pid;,error_log /data/logs/apprise/nginx-error.log;. Insideevents { worker_connections 4096; }. Insidehttp { ... }: includemime.types;client_max_body_size 500M;access_log /data/logs/apprise/nginx-access.log;; rate-limit zone for/statusand/metrics; theupstream apprise_upstream { server unix:/run/apprise/gunicorn.sock max_fails=0; keepalive 16; }; oneserver { listen 8000; listen [::]:8000; ... }block with the full route table copied from upstream (locations for/,/notify,/notify/<key>,/status|metrics,/details|json/urls/...,/cfg,/_/,/cfg|add|del|get/<key>,/s/,/favicon.ico,/robots.txt, catch-all). Final line:include /config/apprise/conf.d/*.conf;(optional include for user-supplied vhost overrides).nginx/mime.types— preserved from the Alpinenginxpackage (we copy it back after wiping/etc/nginx/).nginx/conf.d/.gitkeep— empty placeholder.apprise/apprise.yml.sample— a documented sample apprise YAML config the user can copy into/config/apprise/store/<key>.yml. Comments explain TEXT vs YAML formats.
We don't ship a separate gunicorn.conf.py — the upstream's lives at /usr/local/share/apprise-api/webapp/gunicorn.conf.py and we use it as-is, only overriding the bind path via env (APPRISE_WORKER_COUNT, APPRISE_WORKER_TIMEOUT) when the user wants to.
/config// layout (user-editable)
The framework's __initialize_system_etc symlinks every file under /config/<svc>/ back to its /etc/<svc>/ peer. The user-editable seed mirrors /etc/:
/config/nginx/nginx.conf->/etc/nginx/nginx.conf/config/nginx/conf.d/*.conf-> picked up by theinclude /config/apprise/conf.d/*.conf;line in nginx.conf for user-supplied location overrides/config/apprise/apprise.yml.sample-> documentation/sample/config/apprise/store/— apprise-api persistent config store (Django writes<key>.yml/<key>.cfghere when the user POSTs to/add/<key>). TheAPPRISE_CONFIG_DIRenv points at/config/apprise/store./config/apprise/attach/— optional attachments dir (APPRISE_ATTACH_DIR)./config/apprise/plugin/— optional custom plugin path (APPRISE_PLUGIN_PATHS)./config/secure/auth/{root,user}/apprise_{name,pass}— generated by the framework if the user opts into HTTP basic auth (none by default; apprise-api itself has no built-in auth)./config/env/apprise.sh— per-service env overrides (TZ, APPRISE_WORKER_COUNT, etc.).
ADDITIONAL_CONFIG_DIRS for apprise will be /config/nginx /config/apprise so each one runs through __initialize_system_etc.
init.d/99-apprise.sh
Single init.d script (no separate DB — apprise-api is stateless / file-backed). Based on ampache's 99-ampache.sh structure, with these knobs:
SERVICE_NAME="apprise"SERVICE_USER="apprise",SERVICE_GROUP="apprise", but daemon runs as root by default (Alpine nginx package's user isnginx; we keep it simple and userootfor the start script — gunicorn worker drops privileges if--user/--groupare set, but we follow upstream and keep root).EXEC_CMD_BIN='/usr/local/etc/docker/bin/start-apprise'EXEC_CMD_ARGS=''IS_WEB_SERVER="yes",IS_DATABASE_SERVICE="no",USES_DATABASE_SERVICE="no"WWW_ROOT_DIR="/usr/local/share/apprise-api/webapp",ETC_DIR="/etc/nginx",CONF_DIR="/config/nginx"ADDITIONAL_CONFIG_DIRS="/config/apprise"SERVICE_PORT="8000"__execute_prerun_local:mkdir -p /run/apprise /tmp/apprise /config/apprise/store /config/apprise/attach /config/apprise/plugin /data/logs/apprise;chmod 1777 /tmp/apprise;chown -Rf root:root /run/apprise; exportAPPRISE_CONFIG_DIR=/config/apprise/store,APPRISE_ATTACH_DIR=/config/apprise/attach,APPRISE_PLUGIN_PATHS=/config/apprise/plugin.__run_pre_execute_checks_local:nginx -t -c /etc/nginx/nginx.confto validate config before launch.__update_conf_files_local: replaceREPLACE_TZtoken in any of our shipped configs with${TZ:-UTC}. (Currently only nginx error_log gets one, optional.)PRE_EXEC_MESSAGE="Apprise REST API listening on http://localhost:${SERVICE_PORT:-8000}/".
start-apprise wrapper script
rootfs/usr/local/etc/docker/bin/start-apprise — small bash wrapper:
set -emkdir -p /run/apprise /tmp/apprise /data/logs/apprise /config/apprise/store /config/apprise/attach /config/apprise/pluginchmod 1777 /tmp/apprise- Export the
APPRISE_*env vars (defaults if user hasn't set them). - Start gunicorn in background:
cd /usr/local/share/apprise-api/webapp && gunicorn -c gunicorn.conf.py --worker-tmp-dir /dev/shm core.wsgi >>/data/logs/apprise/gunicorn.log 2>&1 & - Wait up to 15s for
/run/apprise/gunicorn.sockto appear. exec /usr/sbin/nginx -c /etc/nginx/nginx.conf(nginx.conf hasdaemon off;).
The gunicorn config file sets bind = ["unix:/run/apprise/gunicorn.sock"] — we patch this in 05-custom.sh because upstream's path is /tmp/apprise/gunicorn.sock.
05-custom.sh additions
Replace the placeholder content with:
- Wipe distro-default
/etc/nginx/*and copy in our shipped nginx config (preservingmime.typesandfastcgi_paramsfrom the package since we don't ship them):if [ -d /tmp/etc/nginx ]; then [ -f /etc/nginx/mime.types ] && cp -f /etc/nginx/mime.types /tmp/nginx-mime.types.preserve [ -f /etc/nginx/fastcgi_params ] && cp -f /etc/nginx/fastcgi_params /tmp/nginx-fastcgi_params.preserve rm -Rf /etc/nginx/* cp -Rf /tmp/etc/nginx/. /etc/nginx/ [ -f /tmp/nginx-mime.types.preserve ] && mv -f /tmp/nginx-mime.types.preserve /etc/nginx/mime.types [ -f /tmp/nginx-fastcgi_params.preserve ] && mv -f /tmp/nginx-fastcgi_params.preserve /etc/nginx/fastcgi_params mkdir -p /usr/local/share/template-files/config/nginx cp -Rf /etc/nginx/. /usr/local/share/template-files/config/nginx/ fi - Same wipe-and-replace pattern for
/etc/apprise/. - Clone apprise-api at a pinned tag from upstream:
Layout note: upstream repo's
APPRISE_API_VERSION="${APPRISE_API_VERSION:-master}" git clone --depth 1 --branch "$APPRISE_API_VERSION" https://github.com/caronc/apprise-api /usr/local/share/apprise-apiapprise_api/contents become/usr/local/share/apprise-api/. Inside, the Django app is theApprise-APIcheckout's root (it already hasmanage.py,core/,api/,error/,gunicorn.conf.py). We adopt theirwebapp/symlink convention by creating/usr/local/share/apprise-api/webapp -> .so paths in our nginx.conf and start script can reference/usr/local/share/apprise-api/webapp/consistently with upstream. - Patch
gunicorn.conf.pyto point at our socket path:sed -i 's|/tmp/apprise/gunicorn.sock|/run/apprise/gunicorn.sock|g' /usr/local/share/apprise-api/webapp/gunicorn.conf.py pip install --no-cache-dir --break-system-packages apprise PGPy slixmpp smpplib gntp(the deps Alpine doesn't package).- Create runtime dirs:
mkdir -p /run/apprise /tmp/apprise /var/log/nginx /usr/local/share/template-files/config/apprise chmod 1777 /tmp/apprise - Drop a sample apprise YAML (
apprise.yml.sample) into/usr/local/share/template-files/config/apprise/so first-run seeding lands one in/config/apprise/.
04-users.sh additions
The nginx Alpine package creates the nginx user automatically. Add a defensive apprise system user in case the framework's user creation hasn't run yet (addgroup -S apprise 2>/dev/null; adduser -S -G apprise -H -h /var/lib/apprise -s /sbin/nologin apprise 2>/dev/null). Keep it idempotent (the || true pattern).
02-packages.sh additions
Empty placeholder is fine — pip installs go in 05-custom.sh next to the upstream clone (needs to run after the clone so we can also install from the repo's own requirements.txt). Decision: do pip install in 05-custom.sh.
Dockerfile changes
- Update
BUILD_DATEto202605091200(today, 2026-05-09). - Replace
PACK_LIST=" "with the trimmed list above (single line, trailing space). - Change
ARG SERVICE_PORT="80"->"8000"andARG EXPOSE_PORTS="80"->"8000"in the header. (Final-stage labels reuse them.) - Change
PHP_VERSION="system"to"none"(no PHP at all). - Add
ARG CONTAINER_VERSION="USE_DATE"so the YYMM tag is auto-added like ampache. - Keep everything else (multi-stage, scratch final, ENVs, volumes, healthcheck).
.env.scripts changes
- Sync
ENV_PACKAGESto match the newPACK_LIST(single space, no doubles). SERVICE_PORT="8000",EXPOSE_PORTS="".PHP_VERSION="none".
README updates
Document the first-run workflow:
- visit
http://localhost:8000/-> the apprise-api welcome UI. - create a stateful config:
curl -X POST http://localhost:8000/add/mykey -d 'urls=mailto://user:pass@gmail.com'(or POST a YAML body). - send a notification:
curl -X POST http://localhost:8000/notify/mykey -d 'body=hello&title=test'. - stateless one-shot:
curl -X POST http://localhost:8000/notify -d 'urls=json://localhost&body=hi'. - volumes:
/config(apprise YAML/text profiles + nginx overrides),/data(logs, attachments not-mounted-elsewhere). - env vars:
TZ,APPRISE_WORKER_COUNT,APPRISE_WORKER_TIMEOUT,APPRISE_BASE_URL,APPRISE_STATEFUL_MODE.
Verification (success criteria)
cd /root/Projects/github/casjaysdevdocker/apprise && rm -f .build_failed && buildx run Dockerfilesucceeds for bothlinux/amd64andlinux/arm64. Single retry permitted on transient network errors.docker run -d --rm --name test-apprise -p 18000:8000 docker.io/casjaysdevdocker/apprise:latestboots; after ~30sdocker logs test-apprise | tail -50shows nginx + gunicorn started, no fatal errors.curl -fsS -o /dev/null -w '%{http_code}' http://localhost:18000/returns 200.curl -fsS -X POST http://localhost:18000/notify -d 'urls=json://&body=test&title=test'returns 200 (or a clear 4xx if the URL is rejected — note which).docker exec test-apprise ls /config/apprise/store /config/nginx/ /usr/local/share/apprise-api/webapp/manage.py— every path exists.docker stop test-apprise.
Rollback
If anything in this PLAN.md proves wrong, the existing files are recoverable from git (git checkout -- rootfs/). New files (init.d/99-apprise.sh, tmp/etc/, start-apprise) can be removed cleanly because they didn't exist before this migration.