Compare commits

...
187 Commits
Author SHA1 Message Date
Liss-Bot 62d90253cc Updates changelog for v1.4.4 (auto-generated) 2026-11-04 08:27:50 +00:00
Liss-Bot dda00717aa Updates templates (auto-generated, on 04-Nov-2026) 2026-11-04 08:27:24 +00:00
Liss-Bot 9db920e1d3 Updates changelog for v1.4.3 (auto-generated) 2026-11-03 08:55:38 +00:00
Liss-Bot 42546aebab Updates template + source list in docs (auto-generated, on 03-Nov-2026) 2026-11-03 08:55:10 +00:00
Liss-Bot 1a520db72c Updates templates (auto-generated, on 03-Nov-2026) 2026-11-03 08:55:08 +00:00
Liss-Bot 6d0b490eb0 Updates changelog for v1.4.2 (auto-generated) 2026-11-02 11:54:00 +00:00
Liss-Bot 275c4c37aa Updates template + source list in docs (auto-generated, on 02-Nov-2026) 2026-11-02 11:53:35 +00:00
Liss-Bot bed63b0d2f Updates templates (auto-generated, on 02-Nov-2026) 2026-11-02 11:53:34 +00:00
Alicia Sykes e225709d4a Merge pull request #167 from pixari/opentodo-org-move
OpenTodo: update source URL to the new organisation
2026-11-02 11:53:03 +00:00
pixariandClaude Opus 5.5 539e9250b9 OpenTodo: point the source at the OpenTodoApp organisation
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bkw4TZLFDGeMdZR3vWZF13
2026-11-02 12:25:24 +01:00
Liss-Bot c85ac6edac Updates changelog for v1.4.1 (auto-generated) 2026-11-02 08:44:49 +00:00
Liss-Bot 4991945831 Updates template + source list in docs (auto-generated, on 02-Nov-2026) 2026-11-02 08:44:13 +00:00
Liss-Bot 03c0e5a8dd Updates templates (auto-generated, on 02-Nov-2026) 2026-11-02 08:44:11 +00:00
Liss-Bot 788db83c1c Updates changelog for v1.4.0 (auto-generated) 2026-11-01 00:05:37 +00:00
Alicia Sykes e936f93792 Merge pull request #166 from lissy93/dependabot/github_actions/github-actions-34d5a9b9ca
Bump the github-actions group with 3 updates
2026-10-30 15:42:52 +00:00
dependabot[bot] add79a5321 Bump the github-actions group with 3 updates
Bumps the github-actions group with 3 updates: [actions/setup-python](https://github.com/actions/setup-python), [zizmorcore/zizmor-action](https://github.com/zizmorcore/zizmor-action) and [docker/login-action](https://github.com/docker/login-action).


Updates `actions/setup-python` from 7.0.0 to 7.1.0
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](https://github.com/actions/setup-python/compare/5fda3b95a4ea91299a34e894583c3862153e4b97...6d4fd22b20465ccc0f7354476588e8df7c9ca0e0)

Updates `zizmorcore/zizmor-action` from 0.7.1 to 0.8.0
- [Release notes](https://github.com/zizmorcore/zizmor-action/releases)
- [Commits](https://github.com/zizmorcore/zizmor-action/compare/85b2b9fb8e3325e7a23d7d0a9d7ba5ae0f999e3e...53290badfa369cb0bf7ad16342cb62c2fa47ac7b)

Updates `docker/login-action` from 4.6.0 to 4.7.0
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/dbcb813823bdd20940b903addbd779551569679f...81c6f93d4bc991e510b342a00b419b3bb4efc5c6)

---
updated-dependencies:
- dependency-name: actions/setup-python
  dependency-version: 7.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
- dependency-name: zizmorcore/zizmor-action
  dependency-version: 0.8.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
- dependency-name: docker/login-action
  dependency-version: 4.7.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-30 15:05:45 +00:00
Liss-Bot e7843c1a29 Updates changelog for v1.3.28 (auto-generated) 2026-10-30 12:07:53 +00:00
Liss-Bot 867b050c80 Updates template + source list in docs (auto-generated, on 30-Oct-2026) 2026-10-30 12:07:31 +00:00
Liss-Bot 066a42ac73 Updates templates (auto-generated, on 30-Oct-2026) 2026-10-30 12:07:30 +00:00
Alicia Sykes 3cdaeb24d2 Merge pull request #165 from windoze95/fix/cantinarr-transparent-logo
Use transparent PNG for Cantinarr logo
2026-10-30 12:06:55 +00:00
Julian Dice 0f69478c1e fix: use transparent PNG for Cantinarr logo 2026-10-30 00:59:02 -05:00
Liss-Bot 84763f1e24 Updates changelog for v1.3.27 (auto-generated) 2026-10-30 02:07:47 +00:00
Liss-Bot b83d6bb7cd Updates template + source list in docs (auto-generated, on 30-Oct-2026) 2026-10-30 02:07:28 +00:00
Liss-Bot 4fdd3911ba Updates templates (auto-generated, on 30-Oct-2026) 2026-10-30 02:07:27 +00:00
Liss-Bot def0ba49a6 Updates changelog for v1.3.26 (auto-generated) 2026-10-29 02:08:06 +00:00
Liss-Bot 7d82b0f67f Updates template + source list in docs (auto-generated, on 29-Oct-2026) 2026-10-29 02:07:48 +00:00
Liss-Bot 2af28edae7 Updates templates (auto-generated, on 29-Oct-2026) 2026-10-29 02:07:47 +00:00
Liss-Bot ede97aa69f Updates changelog for v1.3.25 (auto-generated) 2026-10-28 02:07:45 +00:00
Liss-Bot 08e274e776 Updates template + source list in docs (auto-generated, on 28-Oct-2026) 2026-10-28 02:07:24 +00:00
Liss-Bot 34c0851aa1 Updates templates (auto-generated, on 28-Oct-2026) 2026-10-28 02:07:22 +00:00
Liss-Bot 5f00408df5 Updates changelog for v1.3.24 (auto-generated) 2026-10-27 11:37:33 +00:00
Liss-Bot 15e082290c Updates template + source list in docs (auto-generated, on 27-Oct-2026) 2026-10-27 11:37:12 +00:00
Liss-Bot 2da1203539 Updates templates (auto-generated, on 27-Oct-2026) 2026-10-27 11:37:11 +00:00
Alicia Sykes c8d1437ad8 Merge pull request #164 from trahloff/add-aldine
Add Aldine template
2026-10-27 11:36:52 +00:00
Toby Rahloff 53042fa4a7 Add Aldine template 2026-10-27 08:19:50 +01:00
Liss-Bot 03bc08f6fa Updates changelog for v1.3.23 (auto-generated) 2026-10-27 02:13:29 +00:00
Liss-Bot e2e9a60bbb Updates template + source list in docs (auto-generated, on 27-Oct-2026) 2026-10-27 02:13:10 +00:00
Liss-Bot 1fe0546e17 Updates templates (auto-generated, on 27-Oct-2026) 2026-10-27 02:13:08 +00:00
Alicia Sykes 54da95bb1a Merge pull request #159 from lissy93/dependabot/github_actions/github-actions-5b1076ef45
Bump zizmorcore/zizmor-action from 0.6.4 to 0.7.1 in the github-actions group
2026-10-25 19:23:21 +00:00
Liss-Bot 0e9293b02d Updates changelog for v1.3.22 (auto-generated) 2026-10-25 18:05:22 +00:00
Liss-Bot f83f81b7ce Updates template + source list in docs (auto-generated, on 25-Oct-2026) 2026-10-25 18:05:01 +00:00
Liss-Bot 5cba342491 Updates templates (auto-generated, on 25-Oct-2026) 2026-10-25 18:05:00 +00:00
Alicia Sykes 3abb4cdfb8 Merge pull request #162 from ulsklyc/add-yuvomi
Add Yuvomi
2026-10-25 18:04:26 +00:00
Ulas Kalayci bfc1cf74b7 Add Yuvomi to sources 2026-10-25 17:08:56 +01:00
Liss-Bot 6ce68e005e Updates changelog for v1.3.21 (auto-generated) 2026-10-25 11:27:44 +00:00
Liss-Bot d85cc04ba9 Updates template + source list in docs (auto-generated, on 25-Oct-2026) 2026-10-25 11:27:23 +00:00
Liss-Bot b1e716bcc9 Updates templates (auto-generated, on 25-Oct-2026) 2026-10-25 11:27:22 +00:00
Alicia Sykes 4fbe2dc26f Merge pull request #161 from armanckeser/add-armanckeser-selfhost-apps
Add armanckeser/selfhost-apps collection source
2026-10-25 11:27:04 +00:00
Armanc Keser ae30fba285 Add armanckeser/selfhost-apps collection source 2026-10-24 20:29:15 -04:00
Liss-Bot c3e6d550d5 Updates changelog for v1.3.20 (auto-generated) 2026-10-24 21:38:49 +00:00
Liss-Bot 8957b7a476 Updates template + source list in docs (auto-generated, on 24-Oct-2026) 2026-10-24 21:38:26 +00:00
Liss-Bot 133bab4805 Updates templates (auto-generated, on 24-Oct-2026) 2026-10-24 21:38:25 +00:00
Alicia Sykes a14ea54cd2 Merge pull request #160 from pixari/add-opentodo
Add OpenTodo (app source)
2026-10-24 22:38:03 +01:00
pixari 8b26cea163 Add OpenTodo app source 2026-10-24 22:37:33 +02:00
dependabot[bot] 52cc589251 Bump zizmorcore/zizmor-action
Bumps the github-actions group with 1 update: [zizmorcore/zizmor-action](https://github.com/zizmorcore/zizmor-action).


Updates `zizmorcore/zizmor-action` from 0.6.4 to 0.7.1
- [Release notes](https://github.com/zizmorcore/zizmor-action/releases)
- [Commits](https://github.com/zizmorcore/zizmor-action/compare/cc914d7f3750a2d13d75c7f184a1060aa0e9d482...85b2b9fb8e3325e7a23d7d0a9d7ba5ae0f999e3e)

---
updated-dependencies:
- dependency-name: zizmorcore/zizmor-action
  dependency-version: 0.7.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-23 14:05:51 +00:00
Liss-Bot e98db73d3f Updates changelog for v1.3.19 (auto-generated) 2026-10-23 02:11:45 +00:00
Liss-Bot f839a40331 Updates template + source list in docs (auto-generated, on 23-Oct-2026) 2026-10-23 02:11:25 +00:00
Liss-Bot ae9c628873 Updates templates (auto-generated, on 23-Oct-2026) 2026-10-23 02:11:24 +00:00
Liss-Bot 1d8406bdaa Updates changelog for v1.3.18 (auto-generated) 2026-10-22 02:11:41 +00:00
Liss-Bot 2e5cbb711d Updates template + source list in docs (auto-generated, on 22-Oct-2026) 2026-10-22 02:11:22 +00:00
Liss-Bot bd5ef0fc6e Updates templates (auto-generated, on 22-Oct-2026) 2026-10-22 02:11:21 +00:00
Liss-Bot a8077bc0e8 Updates changelog for v1.3.17 (auto-generated) 2026-10-21 02:11:57 +00:00
Liss-Bot 07d2928a78 Updates template + source list in docs (auto-generated, on 21-Oct-2026) 2026-10-21 02:11:37 +00:00
Liss-Bot 9c2749d3fc Updates templates (auto-generated, on 21-Oct-2026) 2026-10-21 02:11:35 +00:00
Liss-Bot dbd4451c8b Updates changelog for v1.3.16 (auto-generated) 2026-10-20 02:09:40 +00:00
Liss-Bot 502429b470 Updates template + source list in docs (auto-generated, on 20-Oct-2026) 2026-10-20 02:09:21 +00:00
Liss-Bot 3ee4a27e0f Updates templates (auto-generated, on 20-Oct-2026) 2026-10-20 02:09:19 +00:00
Liss-Bot 35d9519344 Updates changelog for v1.3.15 (auto-generated) 2026-10-19 02:13:09 +00:00
Liss-Bot 598e99d6ac Updates template + source list in docs (auto-generated, on 19-Oct-2026) 2026-10-19 02:12:51 +00:00
Liss-Bot 4d1e31786c Updates templates (auto-generated, on 19-Oct-2026) 2026-10-19 02:12:49 +00:00
Liss-Bot f936887dd1 Updates changelog for v1.3.14 (auto-generated) 2026-10-18 02:10:04 +00:00
Liss-Bot 63c49f5ac2 Updates template + source list in docs (auto-generated, on 18-Oct-2026) 2026-10-18 02:09:47 +00:00
Liss-Bot 2d76612720 Updates templates (auto-generated, on 18-Oct-2026) 2026-10-18 02:09:46 +00:00
Liss-Bot b82e1fc07e Updates changelog for v1.3.13 (auto-generated) 2026-10-17 02:09:35 +00:00
Liss-Bot fd4f91c5ef Updates template + source list in docs (auto-generated, on 17-Oct-2026) 2026-10-17 02:09:16 +00:00
Liss-Bot 738cf5bc13 Updates templates (auto-generated, on 17-Oct-2026) 2026-10-17 02:09:14 +00:00
Liss-Bot 264333d03c Updates changelog for v1.3.12 (auto-generated) 2026-10-16 14:24:56 +00:00
Liss-Bot 3c5436d350 Updates template + source list in docs (auto-generated, on 16-Oct-2026) 2026-10-16 14:24:34 +00:00
Liss-Bot 3538716e70 Updates templates (auto-generated, on 16-Oct-2026) 2026-10-16 14:24:32 +00:00
Alicia Sykes a0c780e189 Merge pull request #157 from lissy93/source/adds-portainer-templates-extras
Adds portainer-templates-extras source
2026-10-16 15:24:08 +01:00
Alicia Sykes 55035a4157 Adds portainer-templates-extras source 2026-10-16 15:18:45 +01:00
Liss-Bot 29cb39f96e Updates changelog for v1.3.11 (auto-generated) 2026-10-16 14:17:35 +00:00
Liss-Bot 81e7b5c54a Updates template + source list in docs (auto-generated, on 16-Oct-2026) 2026-10-16 14:17:12 +00:00
Liss-Bot 4da21f845e Updates templates (auto-generated, on 16-Oct-2026) 2026-10-16 14:17:11 +00:00
Alicia Sykes 7b30d9886d Merge pull request #155 from seawiseinc/add-seawise-client
Add Seawise.io Client app source
2026-10-16 15:16:50 +01:00
Alicia Sykes 73401e0e7a Merge pull request #156 from lissy93/dependabot/github_actions/github-actions-dc5ed02c9f
Bump actions/upload-artifact from 7.0.1 to 7.0.2 in the github-actions group
2026-10-16 15:10:26 +01:00
dependabot[bot] 0f67e544dc Bump actions/upload-artifact
Bumps the github-actions group with 1 update: [actions/upload-artifact](https://github.com/actions/upload-artifact).


Updates `actions/upload-artifact` from 7.0.1 to 7.0.2
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/043fb46d1a93c77aae656e7c1c64a875d1fc6a0a...cf430e030ddbb5b0abf93d22962f4752f3646cd9)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: 7.0.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-16 14:05:11 +00:00
Liss-Bot 984281aa5d Updates changelog for v1.3.10 (auto-generated) 2026-10-16 02:10:02 +00:00
Liss-Bot ffa6023191 Updates template + source list in docs (auto-generated, on 16-Oct-2026) 2026-10-16 02:09:40 +00:00
Liss-Bot 5b3ec8e364 Updates templates (auto-generated, on 16-Oct-2026) 2026-10-16 02:09:39 +00:00
seawiseinc 7ecb258685 Add Seawise.io Client app source 2026-10-15 15:50:58 -04:00
Liss-Bot 5c898d6cdd Updates changelog for v1.3.9 (auto-generated) 2026-10-15 10:26:47 +00:00
Liss-Bot dae9edb827 Updates template + source list in docs (auto-generated, on 15-Oct-2026) 2026-10-15 10:26:25 +00:00
Liss-Bot f5045014d5 Updates templates (auto-generated, on 15-Oct-2026) 2026-10-15 10:26:23 +00:00
Alicia Sykes 4784f6c42e Merge pull request #152 from GeiserX/lynxprompt-smtp-https
LynxPrompt: fix the stack so people can sign in
2026-10-15 11:26:03 +01:00
Liss-Bot 12f5e03798 Updates changelog for v1.3.8 (auto-generated) 2026-10-15 02:10:10 +00:00
Liss-Bot cfe688ab11 Updates template + source list in docs (auto-generated, on 15-Oct-2026) 2026-10-15 02:09:52 +00:00
Liss-Bot bb4130d671 Updates templates (auto-generated, on 15-Oct-2026) 2026-10-15 02:09:51 +00:00
Liss-Bot 44ba5f0019 Updates changelog for v1.3.7 (auto-generated) 2026-10-13 10:06:48 +00:00
Liss-Bot f94020cf2b Updates template + source list in docs (auto-generated, on 13-Oct-2026) 2026-10-13 10:06:28 +00:00
Liss-Bot cb68891cb6 Updates templates (auto-generated, on 13-Oct-2026) 2026-10-13 10:06:26 +00:00
Alicia Sykes 8b817f9c7f Merge pull request #153 from fclairamb/add-solidping
Add SolidPing
2026-10-13 11:06:07 +01:00
Florent Clairambault 2ebd40b016 Add SolidPing to monitoring templates 2026-10-13 07:09:23 +02:00
Liss-Bot 028c199dc1 Updates changelog for v1.3.6 (auto-generated) 2026-10-13 02:23:44 +00:00
Liss-Bot 88e30843c9 Updates template + source list in docs (auto-generated, on 13-Oct-2026) 2026-10-13 02:23:24 +00:00
Liss-Bot 80c7228b63 Updates templates (auto-generated, on 13-Oct-2026) 2026-10-13 02:23:22 +00:00
Liss-Bot 058ca78aa0 Updates changelog for v1.3.5 (auto-generated) 2026-10-12 02:27:07 +00:00
Liss-Bot c8d0c773dc Updates template + source list in docs (auto-generated, on 12-Oct-2026) 2026-10-12 02:26:46 +00:00
Liss-Bot 34c145de73 Updates templates (auto-generated, on 12-Oct-2026) 2026-10-12 02:26:45 +00:00
GeiserX 8bc2878cd6 LynxPrompt: run 2.1.40, which signs in over plain http, and keep uploads in a volume 2026-10-10 23:12:45 +02:00
Liss-Bot ef9d654d70 Updates changelog for v1.3.4 (auto-generated) 2026-10-10 20:59:54 +00:00
Liss-Bot dfbacf94bd Updates template + source list in docs (auto-generated, on 10-Oct-2026) 2026-10-10 20:59:33 +00:00
Liss-Bot ff6d12a3ef Updates templates (auto-generated, on 10-Oct-2026) 2026-10-10 20:59:32 +00:00
Alicia Sykes 9dd0d203ef Merge pull request #151 from yusuf-gundogdu/feat/libredb-self-hosted-source
libredb-studio: move to a self-hosted app source
2026-10-10 21:59:16 +01:00
GeiserX 3162f253f7 LynxPrompt: say in the note where it listens and where the data lives 2026-10-10 22:10:10 +02:00
GeiserX 32f1f2f82e LynxPrompt: make the stack start and let users sign in
The stack put all four Prisma schemas in one database, so each start
dropped the other schemas' tables, and it passed no SMTP settings, so
nobody could get a sign-in link. It now gives each schema its own
database on Postgres 18, pins image 2.1.39, and asks for the SMTP
server, the HTTPS address and the secrets.
2026-10-10 22:01:30 +02:00
yusuf-gundogdu 360326aa2c libredb-studio: move to a self-hosted app source
The copy in sources/local/development_templates.json, added by #140, has
gone stale in the way the guide warns about. Its description says "sixteen
database engines", which was last true around 0.9.x, and its env list is
empty, so STORAGE_PROVIDER is never set: the app then runs in local mode,
PUT /api/storage/connections answers 404 "Server storage is not enabled",
and the /app/data volume the entry declares holds none of the user's saved
connections.

CREATING_A_TEMPLATE asks for the other arrangement, so this follows it. The
template now lives on our own default branch at
https://raw.githubusercontent.com/libredb/libredb-studio/main/portainer-template.json
and this adds the sources.csv row for it. Per step 3 the local copy goes in
the same change, since otherwise it keeps winning and the updates never
show up. There is no overrides.json entry to remove.

The published template is not a copy of the old one. It carries the five
recommended fields the local entry already had, and three env entries it
did not: STORAGE_PROVIDER and STORAGE_SQLITE_PATH as presets, so the volume
actually holds the user's work, and AUTH_COOKIE_SECURE, editable, because
without it the session cookie is marked Secure for any non-loopback host
and sign-in returns to the login page while the container stays healthy.
Measured on a LAN address: without it, zero cookies stored and
GET /api/auth/me 401; with it, cookie stored and 200 authenticated.

title and type match the entry being removed, so nothing in the catalogue
changes name or doubles up.

lib/validate_sources.py against the new URL reports 0 errors and 0
warnings, and against the repository's own default targets reports 0
errors with 32 warnings, all of them pre-existing on other templates.
2026-10-10 18:29:36 +03:00
Liss-Bot 42fc386a40 Updates changelog for v1.3.3 (auto-generated) 2026-10-10 14:05:11 +00:00
Liss-Bot 0aee397e63 Updates template + source list in docs (auto-generated, on 10-Oct-2026) 2026-10-10 14:04:53 +00:00
Liss-Bot afa04c5f36 Updates templates (auto-generated, on 10-Oct-2026) 2026-10-10 14:04:52 +00:00
Alicia Sykes 9b5e710d4f Merge pull request #150 from GeiserX/update-pumperly-description
Pumperly: correct the country count in the description
2026-10-10 15:04:32 +01:00
GeiserX 0ff558dcaf Pumperly: correct the country count in the description 2026-10-10 15:08:03 +02:00
Liss-Bot 04b0f4335e Updates changelog for v1.3.2 (auto-generated) 2026-10-10 06:40:55 +00:00
Liss-Bot eadbf41b48 Updates template + source list in docs (auto-generated, on 10-Oct-2026) 2026-10-10 06:40:35 +00:00
Liss-Bot 6033cfe775 Updates templates (auto-generated, on 10-Oct-2026) 2026-10-10 06:40:34 +00:00
Alicia Sykes cdf3b11390 Merge pull request #149 from BoPeng/add-ai-marketplace-monitor
Add AI Marketplace Monitor (app source)
2026-10-10 07:40:14 +01:00
Bo PengandClaude Opus 5.5 49ebf674eb Add AI Marketplace Monitor (app source)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 20:33:00 -05:00
Liss-Bot 45953bed3b Updates changelog for v1.3.1 (auto-generated) 2026-10-08 02:19:17 +00:00
Liss-Bot 560b47d419 Updates template + source list in docs (auto-generated, on 08-Oct-2026) 2026-10-08 02:18:55 +00:00
Liss-Bot c86ea59b59 Updates templates (auto-generated, on 08-Oct-2026) 2026-10-08 02:18:53 +00:00
Liss-Bot a4e9bc8af6 Updates changelog for v1.3.0 (auto-generated) 2026-10-01 00:08:23 +00:00
Alicia Sykes df38015f7a Merge pull request #148 from lissy93/docs/template-guide
Writes guide for making templates
2026-09-30 22:13:40 +01:00
Alicia Sykes a016cbecbb Writes guide for making templates 2026-09-30 22:12:03 +01:00
Liss-Bot e5aecabdc2 Updates changelog for v1.2.10 (auto-generated) 2026-09-28 13:35:35 +00:00
Liss-Bot dc4e3ef39a Updates template + source list in docs (auto-generated, on 28-Sep-2026) 2026-09-28 13:35:13 +00:00
Liss-Bot 9cdfbe7750 Updates templates (auto-generated, on 28-Sep-2026) 2026-09-28 13:35:11 +00:00
Alicia Sykes c910d5b4cc Merge pull request #147 from SauerExe/add-replayhaven
Add ReplayHaven
2026-09-28 14:34:50 +01:00
SauerExe f1988ecdad Add ReplayHaven 2026-09-28 14:27:20 +02:00
Liss-Bot ca8fbb36e0 Updates changelog for v1.2.9 (auto-generated) 2026-09-27 09:57:11 +00:00
Liss-Bot d29393913d Updates template + source list in docs (auto-generated, on 27-Sep-2026) 2026-09-27 09:56:51 +00:00
Liss-Bot f41ee112d9 Updates templates (auto-generated, on 27-Sep-2026) 2026-09-27 09:56:50 +00:00
Alicia Sykes 7d203e7cab Merge pull request #146 from HarshShah0203/add-homedex
Add Homedex (app source)
2026-09-27 10:56:32 +01:00
Harsh Shah 78870e6139 Add Homedex as an app source 2026-09-27 03:29:58 +05:30
Alicia Sykes 09a5d1e889 Merge pull request #145 from lissy93/feat/remote-template-sources
Allow remote templates, instead of needing to add local templates here
2026-09-26 07:12:11 +01:00
Alicia Sykes 150959f5db Simplifies contributing, and limites to one template per app source 2026-09-26 07:01:04 +01:00
Alicia Sykes ffc252ed99 Validates each source by its kind, to match what the build accepts 2026-09-25 20:00:01 +01:00
Alicia Sykes 067e454fbc Allow remote templates, instead of needing to add local templates here 2026-09-25 19:01:25 +01:00
Liss-Bot d0bca92645 Updates changelog for v1.2.8 (auto-generated) 2026-09-25 02:09:54 +00:00
Liss-Bot 2154091189 Updates template + source list in docs (auto-generated, on 25-Sep-2026) 2026-09-25 02:09:35 +00:00
Liss-Bot 9699bb6b36 Updates templates (auto-generated, on 25-Sep-2026) 2026-09-25 02:09:33 +00:00
Alicia Sykes a512f60246 Updates combine script to be better at finding associated repo 2026-09-24 19:13:54 +01:00
Alicia Sykes 3cd578e5fd Preserves app's GitHub link if found elsewhere 2026-09-24 14:30:48 +01:00
Alicia Sykes ad2d07b8a6 Merge pull request #144 from lissy93/feat/unify-changelog-gen
Identify renames, and share changelog logic releast/tag
2026-09-24 12:46:06 +01:00
Alicia Sykes 6c5d0ae9c4 Identify renames, and share changelog logic releast/tag 2026-09-24 12:43:28 +01:00
Liss-Bot 1936894c5c Updates changelog for v1.2.7 (auto-generated) 2026-09-24 00:17:08 +00:00
Liss-Bot 9960f1efca Updates template + source list in docs (auto-generated, on 24-Sep-2026) 2026-09-24 00:16:49 +00:00
Liss-Bot 82f525f5a0 Updates templates (auto-generated, on 24-Sep-2026) 2026-09-24 00:16:47 +00:00
Alicia Sykes 760b1dc565 Merge pull request #141 from ktaraszk/add-deployable-sh
Add deployable.sh templates source
2026-09-24 01:16:29 +01:00
Liss-Bot aa17c6bdb2 Updates changelog for v1.2.6 (auto-generated) 2026-09-23 15:53:30 +00:00
Liss-Bot af3a7a3e48 Updates template + source list in docs (auto-generated, on 23-Sep-2026) 2026-09-23 15:53:08 +00:00
Liss-Bot 1688e57132 Updates templates (auto-generated, on 23-Sep-2026) 2026-09-23 15:53:07 +00:00
Alicia Sykes 3aa08c7a73 Merge pull request #143 from windoze95/docs/cantinarr-product-first-listing
Refresh Cantinarr listing copy
2026-09-23 16:52:49 +01:00
Julian Dice 58491107ff docs: refresh Cantinarr Portainer listing copy 2026-09-23 10:44:29 -05:00
Alicia Sykes d1ed07a405 Merge branch 'main' of github.com:lissy93/portainer-templates into add-deployable-sh 2026-09-23 15:59:57 +01:00
Liss-Bot fb756e82df Updates changelog for v1.2.5 (auto-generated) 2026-09-23 14:56:10 +00:00
Liss-Bot 4f71eec98b Updates template + source list in docs (auto-generated, on 23-Sep-2026) 2026-09-23 14:55:36 +00:00
Liss-Bot a28b626258 Updates templates (auto-generated, on 23-Sep-2026) 2026-09-23 14:55:35 +00:00
Alicia Sykes 650df354e7 Merge pull request #142 from lissy93/ref/improve-ordering-and-quality
Improves tempalte quality, by picking best ones
2026-09-23 15:55:17 +01:00
Alicia Sykes b0b9a115e4 Merge branch 'main' of github.com:lissy93/portainer-templates into ref/improve-ordering-and-quality 2026-09-23 15:53:40 +01:00
Alicia Sykes f2a9783670 Adds overrides.json for pinning or flagging broken templates 2026-09-23 15:45:57 +01:00
Alicia Sykes 55b0a4b57f Reorders sources so the better lists win on duplicates 2026-09-23 15:45:41 +01:00
Chris Taraszka 5e37bb60ea Add deployable.sh templates source 2026-09-20 23:26:00 +02:00
Alicia Sykes e24b4aad10 Merge pull request #139 from lissy93/dependabot/github_actions/github-actions-ff39958ebb 2026-09-19 22:19:32 +01:00
Liss-Bot a60e3b70cb Updates changelog for v1.2.4 (auto-generated) 2026-09-19 05:37:02 +00:00
Liss-Bot 102723be65 Updates template + source list in docs (auto-generated, on 19-Sep-2026) 2026-09-19 05:36:42 +00:00
Liss-Bot 6a4405789c Updates templates (auto-generated, on 19-Sep-2026) 2026-09-19 05:36:41 +00:00
Alicia Sykes f939b9e56e Merge pull request #140 from cevheri/add-libredb-studio
Add LibreDB Studio to development templates
2026-09-19 06:36:24 +01:00
cevheri d37bb84870 Add LibreDB Studio to development templates 2026-09-19 02:45:43 +03:00
dependabot[bot] c8989323b9 Bump the github-actions group with 4 updates
Bumps the github-actions group with 4 updates: [zizmorcore/zizmor-action](https://github.com/zizmorcore/zizmor-action), [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action), [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) and [docker/build-push-action](https://github.com/docker/build-push-action).


Updates `zizmorcore/zizmor-action` from 0.6.3 to 0.6.4
- [Release notes](https://github.com/zizmorcore/zizmor-action/releases)
- [Commits](https://github.com/zizmorcore/zizmor-action/compare/70fb788f84895a7701f5643d103d587e460b5c99...cc914d7f3750a2d13d75c7f184a1060aa0e9d482)

Updates `docker/setup-qemu-action` from 4.3.0 to 4.4.0
- [Release notes](https://github.com/docker/setup-qemu-action/releases)
- [Commits](https://github.com/docker/setup-qemu-action/compare/1f40c72289eff860ee54a304f1438e3cff362e0a...99012661954931238ded8c8b007157a8430204e1)

Updates `docker/setup-buildx-action` from 4.3.0 to 4.4.0
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/37fe631027851001ddb9b187196cc803df7f5f0e...594f3bf4285d9ea8dc53c9a0c9c4092420091003)

Updates `docker/build-push-action` from 7.3.0 to 7.4.0
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/53b7df96c91f9c12dcc8a07bcb9ccacbed38856a...c3c9e263c25d99ce0380d002d59b67737d91b0dc)

---
updated-dependencies:
- dependency-name: zizmorcore/zizmor-action
  dependency-version: 0.6.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
- dependency-name: docker/setup-qemu-action
  dependency-version: 4.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
- dependency-name: docker/setup-buildx-action
  dependency-version: 4.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
- dependency-name: docker/build-push-action
  dependency-version: 7.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-18 14:05:46 +00:00
Liss-Bot 7ecf18b9b7 Updates changelog for v1.2.3 (auto-generated) 2026-09-04 15:39:07 +00:00
Liss-Bot 7196ced724 Updates templates (auto-generated, on 04-Sep-2026) 2026-09-04 15:38:46 +00:00
Alicia Sykes 1b35191037 Merge pull request #138 from windoze95/cantinarr-push-url
Cantinarr: point the push gateway default at push.cantinarr.com
2026-09-04 16:38:19 +01:00
Alicia Sykes b66772739f Merge pull request #137 from lissy93/dependabot/github_actions/github-actions-88165e1295
Bump the github-actions group with 2 updates
2026-09-04 16:37:13 +01:00
Julian DiceandClaude Fable 5.1 d973af5151 Cantinarr: point the push gateway default at push.cantinarr.com
The relay's canonical address moved. The old push.julian.codes still
answers from the same gateway and current Cantinarr images rewrite it to
the new name at start, so existing stacks are unaffected; only the
default a new stack is offered changes. templates.json is left to the
build workflow, as in #135.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TZPMFuN27JfifMu9tePa16
2026-09-04 09:07:53 -05:00
dependabot[bot] 9ecc013ee3 Bump the github-actions group with 2 updates
Bumps the github-actions group with 2 updates: [zizmorcore/zizmor-action](https://github.com/zizmorcore/zizmor-action) and [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action).


Updates `zizmorcore/zizmor-action` from 0.6.2 to 0.6.3
- [Release notes](https://github.com/zizmorcore/zizmor-action/releases)
- [Commits](https://github.com/zizmorcore/zizmor-action/compare/3dc1ecc9bcb9e94e9b2c709687979e1298497054...70fb788f84895a7701f5643d103d587e460b5c99)

Updates `docker/setup-qemu-action` from 4.2.0 to 4.3.0
- [Release notes](https://github.com/docker/setup-qemu-action/releases)
- [Commits](https://github.com/docker/setup-qemu-action/compare/96fe6ef7f33517b61c61be40b68a1882f3264fb8...1f40c72289eff860ee54a304f1438e3cff362e0a)

---
updated-dependencies:
- dependency-name: zizmorcore/zizmor-action
  dependency-version: 0.6.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
- dependency-name: docker/setup-qemu-action
  dependency-version: 4.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-04 14:06:08 +00:00
Liss-Bot 9dc4ad0b4a Updates changelog for v1.2.2 (auto-generated) 2026-09-04 11:09:56 +00:00
Liss-Bot b142631ecd Updates template + source list in docs (auto-generated, on 04-Sep-2026) 2026-09-04 11:09:35 +00:00
27 changed files with 13403 additions and 2751 deletions

No files matched your search

+1 -1
View File
@@ -7,7 +7,7 @@ Sorry it's playing up! But please read this before opening an issue here - it'll
### Where to report it
- **A bug in the app itself** - report it to that **app's own project**.
- **A wrong template** (dead image, bad port, missing variable) - report it to the **source it came from**. Every app in the [README](https://github.com/lissy93/portainer-templates#supported-apps-and-stacks) has a **"Report issues"** link beside it, pointing straight at its source.
- **A wrong template** (dead image, bad port, missing variable) - report it to the **source it came from**. Every app in the [README](https://github.com/lissy93/portainer-templates#supported-apps-and-stacks) has a **"Report issues"** link beside it, pointing straight at its source. For the growing number of apps whose own authors publish their template, that link goes directly to them, and their fix reaches us the next day.
Once a fix lands upstream, it flows into our list automatically on the next build.
+72 -6
View File
@@ -4,16 +4,82 @@ Thanks for wanting to help out - it's genuinely appreciated! 🙌
This repo compiles Portainer app templates from lots of [sources](../sources.csv) into a single `templates.json`. Most contributions are either adding apps to that list, or improving the tooling around it.
## Adding apps or stacks
## Adding your app
- **Maintain your own template list?** Add its name and raw URL to [`sources.csv`](../sources.csv), and the next build will pull it in.
- **Just have a template or two?** Drop a JSON file into [`sources/local/`](../sources/local). It needs to match [Portainer's template format](https://docs.portainer.io/advanced/app-templates/format) - there's a [`Schema.json`](../Schema.json) you can check against.
- **Adding a docker-compose stack?** Put the compose file in [`sources/stacks/`](../sources/stacks) and point your template at it.
> [!TIP]
> **📖 New to Portainer templates? Follow the full guide: [Creating a Template](CREATING_A_TEMPLATE.md)**<br>
> It walks through every field with examples, how to test your template, and a checklist to go through before you submit.
More detail on all of this is in the [Editing](README.md#editing) section of the README.
> [!TIP]
> Templates added into any of our sources will be automatically pulled into this repo nightly.
> For example, you can submit self-hosted apps to [portainer-templates-extras](https://github.com/notAFlightRisk/portainer-templates-extras), and they'll be included in this repo once merged.
The easiest way is to keep the template in your own repo. We pull it in every day, so you can update it whenever you like, without needing another PR here.
1. Add a `portainer-template.json` file to your repo, containing a single template
2. Add a row to the bottom of [`sources.csv`](../sources.csv) with a name, the raw URL, your repo URL, and `app`
```csv
my_app, https://raw.githubusercontent.com/you/my-app/main/portainer-template.json, https://github.com/you/my-app/, app
```
Your template needs a `type`, `title`, `description`, and either an `image` (for a container) or a `repository` pointing at a compose file (for a stack). It's also worth including a `logo`, `categories`, `platform`, `restart_policy` and a short `note` on getting started, plus whatever `ports`, `volumes` and `env` your app needs. The full format is in [Portainer's docs](https://docs.portainer.io/advanced/app-templates/format), but here's an example:
```json
{
"type": 1,
"title": "My App",
"description": "What it does, in a sentence or two.",
"categories": ["Productivity"],
"platform": "linux",
"logo": "https://raw.githubusercontent.com/you/my-app/main/logo.png",
"image": "ghcr.io/you/my-app:latest",
"restart_policy": "unless-stopped",
"ports": ["8080:8080/tcp"],
"volumes": [{ "container": "/app/data" }],
"env": [{ "name": "TZ", "label": "Timezone", "default": "UTC" }],
"note": "Anything people need to know to get it running."
}
```
Rather not host it yourself? You can drop a JSON file into [`sources/local/`](../sources/local) instead (and any compose file into [`sources/stacks/`](../sources/stacks)), but you'll need a new PR here each time you want to change it.
## Adding a template list
If you maintain a list covering lots of apps, add it to [`sources.csv`](../sources.csv) in the same way, but with `collection` at the end instead of `app`.
## Fixing a broken template
Most apps appear in several sources, and we publish whichever copy comes from the highest-priority source. Sometimes that copy is the bad one - a dead image, a tag that no longer exists, a missing port. Rather than copying the template into `sources/local/`, add an entry to [`overrides.json`](../overrides.json) and point it at a source that has a good copy:
```json
{
"title": "Jenkins",
"prefer": "mikestraney_templates",
"reason": "Portainer's copy pins the retired lts-jdk11 tag",
"updated": "2026-09-23"
}
```
`title` is matched the same way duplicates are deduped, so case and punctuation don't matter. Add `"type": 3` if the app exists as both a container and a stack. `prefer` is a source name exactly as it appears in `sources.csv`.
If **no** source has a working copy, leave `prefer` out and record what's wrong instead. The note is published alongside `templates.json` so the website can flag it:
```json
{
"title": "Elasticsearch",
"status": "broken",
"note": "No source has a working image - upstream publishes no rolling latest tag.",
"updated": "2026-09-23"
}
```
`status` is one of `ok`, `unmaintained` or `broken`. As a last resort you can write the template out into [`sources/local/`](../sources/local) yourself, since local sources beat every external one - but only when nothing upstream works, because anything in there becomes ours to maintain.
The build warns when an override stops doing anything - the app disappeared, the preferred source no longer carries it, or normal priority already picks it - so stale entries are easy to spot and delete.
> [!IMPORTANT]
> If something's broken in one of the **apps themselves** (a bad image, wrong port, etc.), please raise it with that project upstream, not here. This repo only compiles the templates - once upstream fixes it, it'll flow through on the next build.
> If the **app itself** is broken - a bug in the software rather than the template - please raise it with that project upstream. This repo only compiles templates, so an upstream fix flows through on the next build either way. But if **our published template** picks a bad copy of a working app, that one's on us: use `overrides.json` as above.
## Improving the tooling, docs or site
+454
View File
@@ -0,0 +1,454 @@
# Creating a Portainer template
This guide walks through writing a Portainer app template for your own app, so people can deploy it from Portainer's template list in a couple of clicks. You'll end up with a single `portainer-template.json` in your repo, which this project pulls in every day.
In a hurry? Copy one of the [examples](#example-a-container), change the details, then work through the [checklist](#checklist). Already done? Skip to [submitting it](#submitting-it).
## Contents
- [Before you start](#before-you-start)
- [Choose a type](#choose-a-type)
- [Example: a container](#example-a-container)
- [Example: a stack](#example-a-stack)
- [The fields](#the-fields)
- [Getting the format right](#getting-the-format-right)
- [Security](#security)
- [Testing it](#testing-it)
- [Checklist](#checklist)
- [Submitting it](#submitting-it)
## Before you start
You'll need:
- A public Docker image of your app, on Docker Hub, GHCR or any registry that doesn't need a login
- A public git repo to hold the template file, and your compose file if you're making a stack
- The app already running under Docker, so you know its ports, data folders and settings
## Choose a type
| Type | What Portainer deploys | Use it when |
|------|------------------------|-------------|
| `1` | One container, from your image | Your image plus some ports, volumes and env vars is all the app needs |
| `3` | A Docker Compose stack, from a compose file in your repo | The app needs other services to run, like a database, cache or worker |
Go with a container if you can, since it's simpler for people to deploy and tweak. But if the app won't start without a database, make it a stack. Shipping a container and asking users to go and set up Postgres themselves isn't a working template.
Type `2` (Swarm stack) also exists, but it only deploys to Swarm, so this guide doesn't cover it.
## Example: a container
A complete type 1 template, put together from the best ones in this project's list. Copy it and change every value to fit your app.
```json
{
"type": 1,
"title": "My App",
"name": "my-app",
"description": "Self-hosted bookmark manager with full-text search, tags and browser extensions. Source: https://github.com/you/my-app",
"categories": ["Productivity", "Tools"],
"platform": "linux",
"logo": "https://raw.githubusercontent.com/you/my-app/main/docs/logo.png",
"image": "ghcr.io/you/my-app:1",
"restart_policy": "unless-stopped",
"ports": ["8080:8080/tcp"],
"volumes": [{ "container": "/app/data" }],
"env": [
{
"name": "TZ",
"label": "Timezone",
"description": "Used for timestamps and scheduled jobs, e.g. Europe/London",
"default": "UTC"
},
{
"name": "MYAPP_MAX_UPLOAD_MB",
"label": "Largest file people can upload, in MB",
"default": "50"
},
{
"name": "MYAPP_LOG_LEVEL",
"label": "Log level",
"select": [
{ "text": "Info", "value": "info", "default": true },
{ "text": "Debug", "value": "debug" }
]
}
],
"note": "On first start My App prints a one-time admin password to the container log. Open <code>http://&lt;your-host&gt;:8080</code> and sign in as <code>admin</code> with it. Everything is stored in the <code>/app/data</code> volume. Docs: <a href=\"https://github.com/you/my-app#readme\" target=\"_blank\">github.com/you/my-app</a>"
}
```
Notice that it deploys without anyone touching the form. Every input has a working default, and the app generates its own admin password instead of shipping one.
## Example: a stack
A type 3 template points at a compose file in your repo, and its env vars fill in the `${VARIABLES}` in that file.
```json
{
"type": 3,
"title": "My App",
"name": "my-app",
"description": "Self-hosted bookmark manager with full-text search, tags and browser extensions. Source: https://github.com/you/my-app",
"categories": ["Productivity", "Tools"],
"platform": "linux",
"logo": "https://raw.githubusercontent.com/you/my-app/main/docs/logo.png",
"restart_policy": "unless-stopped",
"repository": {
"url": "https://github.com/you/my-app",
"stackfile": "docker-compose.yml"
},
"env": [
{
"name": "MYAPP_DB_PASSWORD",
"label": "Database password (required)",
"description": "Generate one with: openssl rand -hex 32. Keep it the same when you redeploy, or the app can't reach its database."
},
{
"name": "MYAPP_PUBLIC_URL",
"label": "Address you'll open My App at",
"description": "Used to build links in emails. It must match the address in your browser, port included.",
"default": "http://localhost:8080"
},
{
"name": "MYAPP_PORT",
"label": "Host port for the web UI",
"default": "8080"
}
],
"note": "Set a <b>database password</b> before you deploy (<code>openssl rand -hex 32</code> makes a good one). The stack won't start without it. Then open <code>http://&lt;your-host&gt;:8080</code> and create your account. Docs: <a href=\"https://github.com/you/my-app#readme\" target=\"_blank\">github.com/you/my-app</a>"
}
```
And the `docker-compose.yml` it points at, in the root of your repo:
```yaml
services:
app:
image: ghcr.io/you/my-app:1
restart: unless-stopped
ports:
- "${MYAPP_PORT:-8080}:8080"
environment:
MYAPP_PUBLIC_URL: ${MYAPP_PUBLIC_URL:-http://localhost:8080}
MYAPP_DATABASE_URL: postgres://myapp:${MYAPP_DB_PASSWORD:?Set a database password}@db:5432/myapp
volumes:
- app-data:/app/data
depends_on:
db:
condition: service_healthy
db:
image: postgres:17-alpine
restart: unless-stopped
environment:
POSTGRES_DB: myapp
POSTGRES_USER: myapp
POSTGRES_PASSWORD: ${MYAPP_DB_PASSWORD:?Set a database password}
volumes:
- db-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U myapp -d myapp"]
interval: 10s
timeout: 5s
retries: 5
volumes:
app-data:
db-data:
```
Ideally this is the same compose file your README already tells people to use, so there's only one to maintain. For real stacks published this way, see [Homedex](https://github.com/HarshShah0203/homedex/blob/main/portainer-template.json) and [ReplayHaven](https://github.com/SauerExe/ReplayHaven/blob/main/portainer-template.json).
### Writing the compose file
- Portainer clones your repo's default branch and deploys the file at `stackfile`, a path from the repo root. The repo has to be public.
- The template's env vars only fill in `${VARIABLES}` in the compose file. They don't reach your containers unless the compose file passes them in, like `MYAPP_PUBLIC_URL` above.
- Write optional ones as `${VAR:-default}`, with the same default as the template. A blank field then falls back to the default, and the file still works with a plain `docker compose up`.
- Write required ones as `${VAR:?message}`. If someone leaves it blank, the stack fails with your message instead of starting half configured.
- Use published images. Don't use `build:`.
- Use named volumes. A relative bind mount like `./config:/config` won't see the files in your repo, because by default Portainer resolves it against its own data folder, not a checkout on the host.
- Set `restart: unless-stopped` on every service. Portainer ignores the template's `restart_policy` for stacks.
- Give the database a healthcheck, and make the app wait for it with `depends_on` and `condition: service_healthy`.
- Pin databases to a major version, like `postgres:17-alpine`. Moving to a new major version needs a data migration, so it shouldn't happen during a routine update.
- Only publish ports people need to reach. Services talk to each other over the stack's own network, so the database needs no `ports:`.
- Leave out `container_name`, which stops anyone deploying the stack twice, and the top-level `version:` key, which Compose no longer uses.
## The fields
| Field | Type | Container (1) | Stack (3) |
|-------|------|:-------------:|:---------:|
| `type` | number | required | required |
| `title` | string | required | required |
| `description` | string | required | required |
| `image` | string | required | |
| `repository` | object | | required |
| `categories` | list of strings | expected | expected |
| `logo` | URL | expected | expected |
| `note` | string, basic HTML | expected | expected |
| `platform` | `linux` or `windows` | expected | expected |
| `restart_policy` | string | expected | expected |
| `name` | string | optional | optional |
| `env` | list | optional | optional |
| `ports` | list of strings | optional | in compose file |
| `volumes` | list | optional | in compose file |
"Expected" means the validator warns if it's missing. Treat those warnings as things to fix.
### Title and name
- `title` is your app's name, written the way you brand it, like `Homedex`, `ntfy` or `LibreDB Studio`. No extra words like "Docker" or "Self-hosted", and no version numbers.
- If your app is already in the list, use the same title and type. Your template then replaces the community copy, instead of showing up next to it as a duplicate. Case, spaces and punctuation don't matter when matching, so `AdGuard Home` matches `Adguardhome`. Leave off any `(container)` or `(stack)` the website adds to the end. Search [the website](https://portainer-templates.as93.net) to check.
- `name` pre-fills the container or stack name on the deploy form. A stack can't be deployed until someone fills that box in, so set it to save people the typing. Portainer only accepts lowercase letters, numbers, `-` and `_`, so something like `my-app` is perfect.
### Description
- One or two sentences on what the app does. Portainer shows it in the list next to your logo, so shorter reads better.
- Plain text only. Portainer doesn't render Markdown or HTML here, so a `[link](url)` shows up with its brackets.
- End it with `Source: https://github.com/you/my-app`, pointing at your repo. That's how people get from the list or the website to your project.
- Skip the marketing words like "powerful" or "blazing fast", and just say what it does.
### Categories
Pick one to three, most relevant first, and reuse categories that already exist, spelled exactly the same way. Portainer's category filter matches exact names, so if you tag your app `Gaming` while everything similar uses `Games`, it won't show up alongside them.
Good ones to choose from:
> AI, Analytics, Automation, Backup, Books, Chat, CMS, Cloud, Dashboard, Database, Development, DNS, Documents, Downloaders, Email, File Sharing, Finance, Games, Home Automation, Media, Messaging, Monitoring, Music, Networking, Photos, Productivity, Project Management, Proxy, Remote Desktop, Security, Social, Storage, Tools, Video, VPN, Web, Wiki
Avoid `Other`, `Docker` and `Self-hosted`, since they fit every app here and don't help anyone filter. Also avoid `edge`, which Portainer reserves for Edge templates.
To see every category in use, and how often:
```bash
curl -s https://raw.githubusercontent.com/lissy93/portainer-templates/main/templates.json \
| jq -r '.templates[].categories[]?' | sort | uniq -c | sort -rn
```
### Logo
- Use your app's own logo, so people recognise it. If you don't have one yet, your GitHub avatar is a fine fallback.
- Square, with a transparent background, as a PNG or SVG at least 128px across. Portainer shows it small, and the website shows it larger.
- Use a stable `https` URL. A file in your repo, linked through `raw.githubusercontent.com` on your default branch, is ideal. Avoid links that expire or might move, like signed CDN links or release assets.
- If the URL isn't valid, the logo gets dropped and Portainer shows a generic icon instead.
### Note
Portainer shows the note on the deploy form. Use it for whatever people need to get going:
- where to open the app, and on which port
- how the first login works, like a default user, a generated password or a setup page
- any required secrets, and how to generate them
- where the data is stored
- known gotchas, like needing HTTPS or an exact public URL
- a link to your docs
It takes basic HTML such as `<b>`, `<code>`, `<br>` and `<a href="...">`. Portainer strips out images, scripts and inline styles. Markdown doesn't render. To show angle brackets, escape them as `&lt;` and `&gt;`, like the `&lt;your-host&gt;` in the examples.
### Image
- Use your official image, the one you build and publish, not a third-party rebuild.
- Include the registry in the name, like `ghcr.io/you/my-app:1`. Docker Hub images don't need one, so `you/my-app:1` is fine.
- Always set a tag, and pick one you'll keep publishing. A major version tag like `:1` is ideal, because people get fixes without surprise breaking changes. `:latest` works if that's what you publish. A tag for one exact release, like `:1.4.2`, goes stale unless you update the template every release.
- Build for `linux/amd64` and `linux/arm64` if you can. Loads of people run Portainer on a Raspberry Pi or another ARM box.
- Container templates only. For stacks, images go in the compose file.
### Ports
```json
"ports": ["8080:8080/tcp", "2222:22/tcp"]
```
- Each one is a string, in the form `host:container/protocol`.
- The container side must be the port your app listens on inside the container. The host side is just a default, and people can change it on the deploy form.
- Always include the protocol, `/tcp` or `/udp`. Portainer needs it to set up the mapping.
- Don't add a host IP, like `127.0.0.1:8080:8080/tcp`. Portainer's template format doesn't support it, and the deploy fails.
- Only publish what people need to reach. Metrics and internal ports can stay unpublished.
- Avoid 80 and 443 as host defaults, since a reverse proxy usually has them already.
### Volumes
```json
"volumes": [
{ "container": "/app/data" },
{ "container": "/media", "bind": "/srv/media", "readonly": true }
]
```
- `{ "container": "/app/data" }` makes Portainer create a new Docker volume for that path. Use this by default. It works on any host, and it doesn't need the bind mount permission that admins can take away from regular users.
- Add an entry for every path your app writes data to, like its database, config and uploads. Anything left out is lost whenever the container gets recreated, which happens on every update. Caches and temp folders can be skipped.
- If you can, keep all of your app's data under one folder, so a single volume covers it.
- Only use `bind` when people need to point the app at existing files on the host, like a media library. People can change the path on the deploy form. Add `"readonly": true` if the app only reads those files.
### Environment variables
Each entry becomes an input on the deploy form.
| Key | Type | What it does |
|-----|------|--------------|
| `name` | string | The variable's name. Required. |
| `label` | string | The input's label. Always set it, or people see the raw variable name. |
| `description` | string | A tooltip with more detail. |
| `default` | string | Pre-fills the input. |
| `preset` | boolean | When `true`, the `default` is applied and people can't change it. |
| `select` | list | Turns the input into a dropdown. Each option is `{ "text": "...", "value": "..." }`. Mark one with `"default": true`, or the dropdown looks set but sends a blank value. |
- Only list what most people will want to set on their first deploy, and link to your full list of options in the note.
- Give everything a working default, so the app starts without anyone touching the form.
- Values are always strings, like `"default": "8080"` and `"default": "true"`. Only `preset` and the `default` inside a `select` option are real booleans.
- Portainer passes every variable you list, even blank ones, so make sure your app treats an empty value the same as an unset one.
- For stacks, each variable here should appear as a `${VAR}` in your compose file, and each `${VAR}` people should be able to set should be listed here.
Secrets and passwords need a bit more care:
- Never ship a real default secret or password. Everyone who deploys the template would end up sharing it.
- Best of all, have your app generate what it needs on first start and save it to its data volume. Then the template needs no input.
- If people do have to provide one, leave out `default`, put "(required)" in the label, and say how to generate it in the description and the note. `openssl rand -hex 32` is a good suggestion, since hex is safe anywhere, even inside a URL. Portainer won't stop anyone deploying with it blank, so make your app refuse to start without it, or use `${VAR:?message}` in a stack's compose file.
- If your app creates an admin account from env vars, don't default the password to `changeme`. Make it required, or print a one-time password to the log on first start.
### Platform and restart policy
- `platform` is `linux` for almost every image, and Portainer shows it as an icon. Only use `windows` for Windows container images.
- `restart_policy` should be `unless-stopped` for anything long running. Without it Portainer uses `always`, which brings a container you'd stopped back up after a reboot.
- For stacks, set `restart:` in the compose file as well.
### Other fields
| Field | What it's for |
|-------|---------------|
| `command` | Overrides the image's default command, as one string. Better to build the right default into your image. |
| `privileged` | Gives the container full access to the host. Avoid it unless the app can't work without it. |
| `interactive` | Keeps a terminal attached, like `docker run -it`. Rarely needed. |
| `administrator_only` | Meant to hide the template from non-admin users, but current Portainer versions ignore it. Setting it to `true` on a template that needs host access does no harm, just don't rely on it. |
Leave these out:
- `id`, which the build assigns
- `labels`, `network` and `hostname`, since current Portainer versions ignore them when deploying a container template. For a stack, put them in the compose file.
- `registry`, since the registry goes in `image`
- `stackFile`, since stacks should point at your compose file with `repository`
- `maintainer`, which we fill in from `sources.csv`
## Getting the format right
- The file is plain JSON. No comments, and no trailing commas.
- It holds one template, as a single object like the examples.
- Portainer is strict about types. A number or boolean where it expects a string can stop it loading the entire template list, not just yours. Quote everything that's text, including numbers used as text like ports and defaults. Leave `true`, `false` and `type` unquoted.
- Spell field names exactly as shown here. Any field the build or Portainer doesn't recognise gets dropped without a warning, so a typo just quietly disappears. It's `restart_policy` not `restartPolicy`, `volumes` not `volume`, and `env` not `environment`.
## Security
A template runs with whatever access it asks for, on somebody else's server. Ask for only what the app needs.
- No secrets anywhere in the template or the compose file.
- No default passwords that stay valid after setup. See [secrets and passwords](#environment-variables) above.
- Only mount the Docker socket if the app works with containers. Even mounted read-only, the socket gives full control of Docker. If the app only needs to read, put a socket proxy like [`tecnativa/docker-socket-proxy`](https://github.com/Tecnativa/docker-socket-proxy) in front of it, the way [Homedex's compose file](https://github.com/HarshShah0203/homedex/blob/main/docker-compose.yml) does.
- Only use `privileged`, host networking, devices or extra capabilities when there's no other way, and explain why in the note.
- Portainer admins can block bind mounts, privileged mode and devices for regular users, so a template that needs them may only deploy for admins. Say so in the note.
- If the first person to open your setup page becomes the admin, say so in the note, so people finish setup before anyone else can reach it.
## Testing it
A template can pass every check and still not run, so test it three ways.
**1. Check the format.** Clone this repo and run the validator against your file's raw URL:
```bash
git clone https://github.com/lissy93/portainer-templates.git
cd portainer-templates
python3 -m venv .venv && . .venv/bin/activate
make install_requirements
python3 lib/validate_sources.py https://raw.githubusercontent.com/you/my-app/main/portainer-template.json
```
Fix every error, and the warnings too. The same check runs automatically on your PR.
**2. For stacks, check the compose file on its own.** Set any required variables, then run:
```bash
docker compose -f docker-compose.yml config # prints the file with every variable filled in
docker compose -f docker-compose.yml up -d
```
**3. Deploy it from Portainer.** This is the real test, since it's exactly what your users will do. Portainer reads a list of templates, so wrap yours in one and put it somewhere public, like a GitHub Gist:
```json
{
"version": "3",
"templates": [
{ "type": 1, "title": "My App", "...": "the rest of your template" }
]
}
```
In Portainer, go to Settings, paste the raw URL into the App Templates URL field and save. Then open your environment's app templates, find your app, and deploy it to a clean host, leaving the form at its defaults apart from any required secrets. Check that:
- every input on the form has a clear label and a sensible default
- it deploys, and the app opens on the expected port. Stacks deploy in the background, so check the stack's status in the Stacks list, not just the success message
- you can log in and actually use it
- your data survives. Add something, then recreate the container or redeploy the stack while keeping its volumes, and make sure it's still there
- it comes back by itself after the host restarts
When you're done, set the App Templates URL back to what it was, or to this project's list at `https://raw.githubusercontent.com/Lissy93/portainer-templates/main/templates.json`.
> [!TIP]
> Your `portainer-template.json` can use this wrapped format too, as long as it holds just one template. Then you can point Portainer straight at it while testing.
## Checklist
Go through this before you submit.
**It works**
- [ ] It deploys and starts with the default values. The only fields people must fill in are secrets that can't have a safe default, and those are clearly marked.
- [ ] I've deployed it from Portainer itself, on a clean host.
- [ ] The container ports match what the app actually listens on.
- [ ] Every path the app writes data to has a volume, and the data survives the container being recreated.
- [ ] It uses named volumes, with bind mounts only where people need to reach host files.
- [ ] Stacks only: the compose file is on the default branch of a public repo, uses published images and named volumes, and has a `${VAR}` for every variable in `env`.
**It's valid**
- [ ] `python3 lib/validate_sources.py <your URL>` passes with no errors or warnings.
- [ ] The file holds one template, with no `id`.
- [ ] Text values are quoted, and booleans aren't.
**It's unique**
- [ ] The app isn't already in the list under another name. If it's there already, I've used the same title and type so mine replaces it.
- [ ] The title is the app's real name.
- [ ] The logo is the app's own, square, at a stable URL.
**It's described well**
- [ ] The description is one or two plain text sentences, ending with `Source: https://github.com/you/my-app`.
- [ ] It has one to three existing categories.
- [ ] The note covers the port, the first login, any required secrets and where the data lives.
- [ ] Every env var has a label.
**It's safe**
- [ ] It uses the official image, with a tag I keep publishing, built for arm64 as well as amd64 where possible.
- [ ] There are no secrets or default passwords in the template or compose file.
- [ ] Docker socket access, privileged mode, host networking and devices are only there if the app can't work without them, and the note explains why.
## Submitting it
1. Commit `portainer-template.json` to your repo's default branch.
2. Add a row to the bottom of [`sources.csv`](../sources.csv), with a name, the raw URL of your file, your repo URL, and `app`:
```csv
my_app, https://raw.githubusercontent.com/you/my-app/main/portainer-template.json, https://github.com/you/my-app/, app
```
The name has to be unique, start with a lowercase letter or number, and only use lowercase letters, numbers, `_` and `-`.
3. If your app already has a copy in one of the files in [`sources/local/`](../sources/local), remove that entry in the same PR, along with any entry for it in [`overrides.json`](../overrides.json). Otherwise the old copy keeps winning and your updates never show up.
4. Open a PR here.
Once it's merged, you can edit the template in your own repo whenever you like, and changes show up here within a day. Keep the URL pointing at a branch rather than a tag or a commit.
Run the validator again after every change. If the file moves, stops being valid JSON, gains a second template or fails the checks, your app quietly drops out of the list until it's fixed, and nobody gets told.
Each app source holds one template. If you'd like to offer a second version, like a GPU build, publish it as a separate file with its own title, and add another row for it.
For anything else, like adding a whole list of templates, see the [contributing guide](CONTRIBUTING.md).
+4 -1
View File
@@ -4,7 +4,7 @@ So that your PR can be handled quickly, please ensure you've followed .github/CO
-->
### Category
<!-- New template / new source / tooling / docs / workflows / security -->
<!-- New app source / new template / new collection source / tooling / docs / workflows / security -->
### Summary
<!--
@@ -17,3 +17,6 @@ If it relates to an issue, mention the ticket num here (like "closes #420")
- [ ] I've read the [contributing guide](.github/CONTRIBUTING.md)
- [ ] I've self-reviewed my changes to ensure they are valid
- [ ] I'm not modifying `templates.json` directly (it's auto-generated)
<!-- Only if you're registering your own app in sources.csv with kind `app`: -->
- [ ] My template URL points at a branch (not a tag or commit), so updates flow through
- [ ] I've removed my app's old copy from `sources/local/`, if it had one
+927 -696
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+2 -1
View File
@@ -11,6 +11,7 @@ on:
paths:
- 'sources/**'
- 'sources.csv'
- 'overrides.json'
# Read by default; the build job elevates to write to commit
permissions:
@@ -41,7 +42,7 @@ jobs:
# Downloads + installs Python (used for running gen scripts)
- name: Set up Python 🐍
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
with:
python-version: '3.x'
+2 -2
View File
@@ -30,7 +30,7 @@ jobs:
persist-credentials: false
- name: Set up Python 🐍
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
with:
python-version: '3.x'
@@ -73,7 +73,7 @@ jobs:
if [ -f .github/changelog.json ]; then
versions=$(jq '.entries | length' .github/changelog.json)
latest=$(jq -r '.entries[0]
| "\(.version): ✨ \(.added | length) added · 🗑️ \(.removed | length) removed · ♻️ \(.updated | length) updated"
| "\(.version): ✨ \(.added | length) added · 🗑️ \(.removed | length) removed · 🏷️ \(.renamed | length) renamed · ♻️ \(.updated | length) updated"
' .github/changelog.json)
echo "| Versions | ${versions} |"
echo "| Latest | ${latest} |"
+36 -10
View File
@@ -46,6 +46,7 @@ jobs:
- 'sources/stacks/**'
sources_csv:
- 'sources.csv'
- 'overrides.json'
scripts:
- 'lib/**'
- 'Schema.json'
@@ -67,7 +68,7 @@ jobs:
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
with:
python-version: '3.x'
cache: pip
@@ -93,7 +94,7 @@ jobs:
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
with:
python-version: '3.x'
cache: pip
@@ -105,7 +106,7 @@ jobs:
- name: Validate compose stack files
run: python lib/validate_sources.py sources/stacks
# Validate the external sources list (structure + unique names + http urls)
# Validate the external sources list + the per-template overrides that pin against it
sources-list:
name: 📇 Sources List
runs-on: ubuntu-latest
@@ -117,9 +118,11 @@ jobs:
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
# Deep enough to diff sources.csv against the PR's base
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
with:
python-version: '3.x'
cache: pip
@@ -128,8 +131,31 @@ jobs:
- name: Install dependencies
run: pip install -r lib/requirements.txt
- name: Validate sources.csv
run: python lib/validate_sources.py sources.csv
- name: Validate sources.csv + overrides.json
run: python lib/validate_sources.py sources.csv overrides.json
# Check source urls this PR changes: a collection the build can't read stops the nightly
- name: Validate newly added sources
if: github.event_name == 'pull_request'
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: |
set -euo pipefail
# '|| true' since grep exits 1 when this PR only touches overrides.json
urls=$(git diff "$BASE_SHA" -- sources.csv \
| grep '^+[^+]' | cut -c2- \
| awk -F, '{ gsub(/[[:blank:]]/, "", $2)
if ($2 ~ /^https?:\/\//) print $2 }' || true)
if [ -z "$urls" ]; then
echo "This PR changes no source urls, nothing to fetch."
exit 0
fi
status=0
for url in $urls; do
echo "--- Validating $url"
python lib/validate_sources.py "$url" || status=1
done
exit $status
# When the tooling or schema changes: compile, self-check the schema, re-validate output
scripts:
@@ -145,7 +171,7 @@ jobs:
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
with:
python-version: '3.x'
cache: pip
@@ -191,7 +217,7 @@ jobs:
fail-on-error: true
- name: Run zizmor
uses: zizmorcore/zizmor-action@3dc1ecc9bcb9e94e9b2c709687979e1298497054 # v0.6.2
uses: zizmorcore/zizmor-action@53290badfa369cb0bf7ad16342cb62c2fa47ac7b # v0.8.0
with:
# scan the whole workflows dir
inputs: .github/workflows/
@@ -211,7 +237,7 @@ jobs:
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
with:
python-version: '3.x'
cache: pip
@@ -231,7 +257,7 @@ jobs:
run: make combine validate
- name: Upload templates.json
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: templates-json
path: templates.json
+5 -5
View File
@@ -55,19 +55,19 @@ jobs:
org.opencontainers.image.licenses=MIT
- name: 🔧 Set up QEMU
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0
uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4.4.0
- name: 🔧 Set up Docker Buildx
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0
- name: 🔑 Login to DockerHub
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
with:
username: ${{ secrets.DOCKER_USER }}
password: ${{ secrets.DOCKER_TOKEN }}
- name: 🔑 Login to GitHub Container Registry
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -77,7 +77,7 @@ jobs:
uses: crazy-max/ghaction-docker-status@8924b6134452fab7da5c3fdf17f5c4befd1fc244 # v4
- name: ⚒️ Build and push
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
with:
context: .
file: ./Dockerfile
+20 -29
View File
@@ -39,6 +39,11 @@ jobs:
# We authenticate gh with an explicit token below
persist-credentials: false
- name: Set up Python 🐍
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
with:
python-version: '3.x'
- name: Resolve tags
id: refs
env:
@@ -89,16 +94,6 @@ jobs:
exit 1
fi
git show "${TARGET}:templates.json" > new.json
# templates.json at the previous release (empty for a first release)
if [ -n "$PREV" ] && git cat-file -e "${PREV}:templates.json" 2>/dev/null; then
git show "${PREV}:templates.json" > old.json
else
if [ -n "$PREV" ]; then
echo "::warning::templates.json not found at ${PREV}; treating as first release"
fi
echo '{"templates":[]}' > old.json
fi
total=$(jq '.templates | length' new.json)
# Release title, e.g. "Portainer Templates v0.11.0 (June 2026)". Month comes
@@ -108,20 +103,12 @@ jobs:
[ -n "$month" ] || month=$(date -u '+%B %Y')
title="Portainer Templates ${TARGET} (${month})"
# Key by title, minus the `id` (it renumbers each regen -> false "updated")
jq -S '.templates | map({(.title): (del(.id))}) | add // {}' new.json > new_map.json
jq -S '.templates | map({(.title): (del(.id))}) | add // {}' old.json > old_map.json
jq -n --slurpfile n new_map.json --slurpfile o old_map.json '
$n[0] as $n | $o[0] as $o |
{
added: (($n | keys) - ($o | keys)),
removed: (($o | keys) - ($n | keys)),
updated: [ $n | keys[] as $k | select( ($o | has($k)) and ($n[$k] != $o[$k]) ) | $k ]
}' > diff.json
# Shares the changelog's diff, so a rename isn't a removal plus an addition
python3 lib/changelog.py --diff "$PREV" "$TARGET" > diff.json
added=$(jq '.added | length' diff.json)
removed=$(jq '.removed | length' diff.json)
renamed=$(jq '.renamed | length' diff.json)
updated=$(jq '.updated | length' diff.json)
# Render the release notes (lists capped inside jq)
@@ -137,11 +124,12 @@ jobs:
def slugify: ascii_downcase | gsub("[^a-z ]"; "") | gsub(" "; "-");
def md_esc: gsub("(?<x>[\\[\\]])"; "\\\(.x)");
def item(link): if link then "- [\(md_esc)](\($site)/\(. | slugify))" else "- \(.)" end;
def section(title; arr; cap; link):
def renamed_item: "- [\(.to | md_esc)](\($site)/\(.to | slugify)) (was \(.from | md_esc))";
def section(title; arr; cap; render):
if (arr | length) == 0 then empty
else
"### \(title) (\(arr | length))",
(arr[0:cap][] | item(link)),
(arr[0:cap][] | render),
(if (arr | length) > cap
then "- …and \((arr | length) - cap) more"
else empty end),
@@ -151,9 +139,10 @@ jobs:
( if $prev == "" then empty
else
"",
section("➕ Added"; $d.added; 40; true),
section("➖ Removed"; $d.removed; 40; false),
section("🔄 Updated"; $d.updated; 40; true),
section("➕ Added"; $d.added; 40; item(true)),
section("➖ Removed"; $d.removed; 40; item(false)),
section("🏷️ Renamed"; $d.renamed; 40; renamed_item),
section("🔄 Updated"; $d.updated; 40; item(true)),
"[Full diff](\($repo)/compare/\($prev)...\($cur))"
end )
' > notes.md
@@ -161,11 +150,11 @@ jobs:
# The tag's templates.json becomes the release asset
cp new.json templates.json
echo "Changelog: +${added} added, -${removed} removed, ~${updated} updated (${total} total)"
echo "Changelog: +${added} added, -${removed} removed, ${renamed} renamed, ~${updated} updated (${total} total)"
# Skip publishing if nothing changed -- unless it's the first release
# (no previous), which is always worth cutting.
if [ -n "$PREV" ] && [ "$((added + removed + updated))" -eq 0 ]; then
if [ -n "$PREV" ] && [ "$((added + removed + renamed + updated))" -eq 0 ]; then
changed=false
echo "::notice::No template changes since ${PREV}; skipping the release"
else
@@ -176,6 +165,7 @@ jobs:
echo "total=${total}"
echo "added=${added}"
echo "removed=${removed}"
echo "renamed=${renamed}"
echo "updated=${updated}"
echo "changed=${changed}"
echo "title=${title}"
@@ -216,6 +206,7 @@ jobs:
TOTAL: ${{ steps.notes.outputs.total }}
ADDED: ${{ steps.notes.outputs.added }}
REMOVED: ${{ steps.notes.outputs.removed }}
RENAMED: ${{ steps.notes.outputs.renamed }}
UPDATED: ${{ steps.notes.outputs.updated }}
CHANGED: ${{ steps.notes.outputs.changed }}
RESULT: ${{ steps.publish.outputs.result }}
@@ -239,5 +230,5 @@ jobs:
fi
echo "| Previous | \`${PREV:-none}\` |"
echo "| Templates | ${TOTAL:-n/a} total |"
echo "| Changes | ✨ ${ADDED:-0} added · 🗑️ ${REMOVED:-0} removed · ♻️ ${UPDATED:-0} updated |"
echo "| Changes | ✨ ${ADDED:-0} added · 🗑️ ${REMOVED:-0} removed · 🏷️ ${RENAMED:-0} renamed · ♻️ ${UPDATED:-0} updated |"
} >> "$GITHUB_STEP_SUMMARY"
+2
View File
@@ -1,6 +1,8 @@
FROM nginx:stable-alpine
COPY templates.json /usr/share/nginx/html/templates.json
# Served alongside the templates so the site can flag known-broken apps
COPY overrides.json /usr/share/nginx/html/overrides.json
COPY index.html /usr/share/nginx/html/index.html
EXPOSE 80
+94 -11
View File
@@ -2,12 +2,13 @@
Walks every vMAJOR.MINOR.PATCH tag in version order, diffs the compiled
templates.json between consecutive tags, and records the templates added,
removed and updated in each version. Rebuilt from scratch on every run, so
the output is deterministic (safe to re-run, self-heals, no commit churn).
removed, renamed and updated in each version. Rebuilt from scratch on every
run, so the output is deterministic (safe to re-run, self-heals, no churn).
Matches the diff semantics of the release notes in release.yml: templates
are keyed by title, with `id` ignored (it renumbers on every regeneration,
which would otherwise flag everything as updated).
`--diff <old-tag> <new-tag>` instead prints a single diff as JSON, which is
how release.yml builds its release notes, so both share these semantics:
templates are keyed by title, with `id` ignored (it renumbers on every
regeneration, which would otherwise flag everything as updated).
"""
import json
import os
@@ -61,17 +62,91 @@ def templates_at(tag):
return {t['title']: {k: v for k, v in t.items() if k != 'id'}
for t in templates if isinstance(t, dict) and 'title' in t}
def by_name(template):
"""Bare letters and digits of a title, so 'Node Red' and 'Node-Red' match"""
return re.sub(r'[^a-z0-9]', '', template['title'].lower())
def by_body(template):
"""Every field but the title, so a template under a new name still matches"""
return json.dumps({k: v for k, v in template.items() if k != 'title'}, sort_keys=True)
def match_renames(old, new, added, removed):
"""Renames, matched on title then body and dropped from `added` and `removed`.
A pair only counts when exactly one candidate matches, so splits stay honest."""
renamed = []
for key in (by_name, by_body):
candidates = {}
for title in added:
candidates.setdefault(key(new[title]), []).append(title)
for title in list(removed):
identity = key(old[title])
if len(candidates.get(identity, [])) == 1:
new_title = candidates.pop(identity)[0]
renamed.append({'from': title, 'to': new_title})
added.remove(new_title)
removed.remove(title)
return sorted(renamed, key=lambda pair: pair['from'])
def diff(old, new):
"""Added / removed / updated templates between two title-keyed maps"""
"""Added / removed / renamed / updated templates between two title-keyed maps"""
added = sorted(new.keys() - old.keys())
removed = sorted(old.keys() - new.keys())
renamed = match_renames(old, new, added, removed)
updated = []
for title in sorted(new.keys() & old.keys()):
if new[title] != old[title]:
fields = sorted(k for k in new[title].keys() | old[title].keys()
if new[title].get(k) != old[title].get(k))
updated.append({'title': title, 'fields': fields})
return added, removed, updated
return added, removed, renamed, updated
def renames_between(old_tag, previous, new_tag):
"""Every rename step from old_tag to new_tag, one tag at a time. A release spans
several tags, and a title renamed twice only matches up when each hop is paired."""
chain = {}
for tag in semver_tags():
if not version(old_tag) < version(tag) <= version(new_tag):
continue
current = templates_at(tag)
if current is None:
continue
added = sorted(current.keys() - previous.keys())
removed = sorted(previous.keys() - current.keys())
for pair in match_renames(previous, current, added, removed):
chain[pair['from']] = pair['to']
previous = current
return chain
def final_title(chain, title):
"""The name a title ends up under once its renames are followed, loops aside"""
seen = {title}
while title in chain and chain[title] not in seen:
title = chain[title]
seen.add(title)
return title
def print_diff(old_tag, new_tag):
"""Print one tag-to-tag diff as JSON, for the release notes in release.yml.
An empty old_tag, or one with no templates.json, counts as a first release."""
new = templates_at(new_tag)
if new is None:
log.error(f'No templates.json at {new_tag}, nothing to diff')
sys.exit(1)
old = templates_at(old_tag) if old_tag else {}
if old is None:
log.warning(f'No templates.json at {old_tag}, treating as a first release')
old = {}
added, removed, renamed, updated = diff(old, new)
chain = renames_between(old_tag, old, new_tag) if old else {}
for title in list(removed):
landed = final_title(chain, title)
if landed in added:
renamed.append({'from': title, 'to': landed})
added.remove(landed)
removed.remove(title)
print(json.dumps({'added': added, 'removed': removed,
'renamed': sorted(renamed, key=lambda pair: pair['from']),
'updated': [item['title'] for item in updated]}))
def main():
banner('Changelog', 'Diff templates.json between version tags')
@@ -89,17 +164,19 @@ def main():
log.warning(f'{tag}: no templates.json at this tag, skipping')
continue
first = previous_map is None
added, removed, updated = ([], [], []) if first else diff(previous_map, current)
added, removed, renamed, updated = \
([], [], [], []) if first else diff(previous_map, current)
entries.append({
'version': tag,
'previous': previous_tag,
'date': dates.get(tag),
'templateCount': len(current),
'added': added, 'removed': removed, 'updated': updated,
'added': added, 'removed': removed,
'renamed': renamed, 'updated': updated,
})
log.info(f'{tag}: first version ({len(current)} total)' if first else
f'{tag}: +{len(added)} added, -{len(removed)} removed, '
f'~{len(updated)} updated ({len(current)} total)')
f'~{len(updated)} updated, {len(renamed)} renamed ({len(current)} total)')
previous_tag, previous_map = tag, current
entries.reverse() # newest first
@@ -109,4 +186,10 @@ def main():
log.info(f'Wrote {len(entries)} versions to {os.path.relpath(output_path, root_dir)}')
if __name__ == '__main__':
main()
if sys.argv[1:2] == ['--diff']:
if len(sys.argv) != 4:
log.error('Usage: changelog.py --diff <old-tag> <new-tag>')
sys.exit(1)
print_diff(sys.argv[2], sys.argv[3])
else:
main()
+174 -34
View File
@@ -1,12 +1,13 @@
import csv
import json
import os
import re
import string
import sys
from collections import Counter
import jsonschema
import sources_list
from log import get_logger, banner
log = get_logger()
@@ -18,6 +19,8 @@ with open(os.path.join(BASE_DIR, 'Schema.json')) as f:
SCHEMA = json.load(f)
FORMAT_CHECKER = jsonschema.FormatChecker()
TEMPLATE_KEYS = set(SCHEMA['properties']['templates']['items']['properties'])
ITEM_VALIDATOR = jsonschema.Draft7Validator(SCHEMA['properties']['templates']['items'],
format_checker=FORMAT_CHECKER)
TYPE_LABELS = {1: 'container', 2: 'swarm', 3: 'stack', 4: 'edge'}
@@ -32,6 +35,8 @@ def normalize_category(c):
"""Split a category into words and title-case it for display"""
if not isinstance(c, str):
return ''
# Some sources namespace their categories, and the subtype is the useful half
c = c.rsplit(':', 1)[-1].strip() or c
words = [w for w in re.split(r'[^0-9a-zA-Z]+', c) if w]
label = ' '.join(w.upper() if w.lower() in CATEGORY_ACRONYMS else w.capitalize()
for w in words)
@@ -45,8 +50,38 @@ def template_score(t):
score += len(t.get('ports', []))
return score
def load_sources():
def template_rank(t, pinned=None):
"""Sort key for choosing between duplicates, higher wins: pinned source, then source
priority, then completeness."""
return (t['_source'] == pinned, -t['_priority'], template_score(t))
def source_priority(sources):
"""File name -> dedup priority, lower wins: app sources first, then sources.csv order"""
ordered = [s for s in sources if s.is_app] + [s for s in sources if not s.is_app]
return {s.filename: i for i, s in enumerate(ordered, start=1)}
def load_overrides():
"""Per-template fixes from overrides.json, keyed by (normalized title, type)."""
path = os.path.join(BASE_DIR, 'overrides.json')
if not os.path.isfile(path):
return {}
with open(path) as f:
try:
entries = json.load(f).get('overrides', [])
except json.decoder.JSONDecodeError as err:
log.error(f'Refusing to build: overrides.json is not valid JSON ({err})')
sys.exit(1)
return {(normalize_string(e['title']), e.get('type', 1)): e for e in entries
if isinstance(e, dict) and isinstance(e.get('title'), str) and e['title'].strip()}
def pinned_source(overrides, key):
"""The source file name an override pins this (title, type) to, if any."""
prefer = overrides.get(key, {}).get('prefer')
return f'{prefer}.json' if prefer else None
def load_sources(sources):
"""Load and merge all template JSON files from sources/local/ and sources/external/."""
rank = source_priority(sources)
templates = []
local_dir = os.path.join(SOURCES_DIR, 'local')
external_dir = os.path.join(SOURCES_DIR, 'external')
@@ -64,9 +99,11 @@ def load_sources():
log.warning(f'Skipping source due to error: {f.name} ({err})')
continue
source_templates = [t for t in source_templates if isinstance(t, dict)]
# Dedup priority: local files first, then external sources in sources.csv order
priority = 0 if is_local else rank.get(file, len(rank) + 1)
for t in source_templates:
t['_source'] = file
t['_local'] = is_local
t['_priority'] = priority
templates += source_templates
return templates
@@ -154,6 +191,11 @@ def normalize_template(t):
t['ports'] = []
t['ports'] = [s for s in (str(p).lstrip(':') for p in t['ports']) if s]
# An untagged image resolves to ':latest', so make that explicit
image = t.get('image')
if isinstance(image, str) and image and ':' not in image.split('/')[-1]:
t['image'] = f'{image}:latest'
if isinstance(t.get('maintainer'), str):
t['maintainer'] = t['maintainer'].strip()
@@ -187,7 +229,7 @@ def normalize_template(t):
if 'volumes' in t:
t['volumes'] = cleaned_volumes
# Drop fields outside the schema ('_source'/'_local' tags are kept for dedup, stripped later)
# Drop fields outside the schema ('_source'/'_priority' tags are kept for dedup, stripped later)
for k in list(t):
if k not in TEMPLATE_KEYS and not k.startswith('_'):
del t[k]
@@ -203,6 +245,12 @@ def normalize_template_fields(templates):
log.warning(f'Skipping unnormalizable template: {t.get("title", "<no title>")} ({err})')
return normalized
def schema_errors(t):
"""Schema violations in one template, so a bad entry is dropped instead of failing the build"""
probe = {k: v for k, v in t.items() if not k.startswith('_')}
probe.setdefault('id', 1) # combine assigns real ids at write time
return [e.message for e in ITEM_VALIDATOR.iter_errors(probe)]
def is_valid_template(t):
"""Check a template has the required fields for its type."""
if not (isinstance(t.get('title'), str) and t['title'].strip()):
@@ -220,28 +268,54 @@ def is_valid_template(t):
return False
return True
def deduplicate_and_normalize(templates):
"""Filter invalid, deduplicate by (title, type) keeping best version, and normalize category names."""
GITHUB_LINK = re.compile(r'\bgithub\.com/([\w.-]+)/([\w.-]+)', re.I)
# Owners and monorepos that republish other people's apps, so aren't the app itself
NOT_THE_APP = {'sponsors', 'orgs', 'apps', 'topics', 'about', 'features', 'marketplace',
'linuxserver', 'pi-hosted/pi-hosted'}
def app_repo(t):
"""The app's own repo: the first github link in its text, else its GHCR namespace."""
image = str(t.get('image') or '').split('@')[0].split(':')[0].split('/')
match = GITHUB_LINK.search(f'{t.get("description") or ""} {t.get("note") or ""}')
found = [match.groups()] if match else []
if len(image) == 3 and image[0] == 'ghcr.io':
found.append(tuple(image[1:]))
for owner, repo in found:
if repo and not {owner.lower(), f'{owner}/{repo}'.lower()} & NOT_THE_APP:
return f'{owner}/{repo}'
return None
def backfill_app_source(templates, candidates):
"""Re-attach the app's repo where dedup or the container/stack split lost it."""
known = {}
for t in sorted(candidates, key=lambda t: t.get('_priority', 0)):
repo = app_repo(t)
if repo and isinstance(t.get('title'), str):
known.setdefault(normalize_string(t['title']), repo)
filled = 0
for t in templates:
repo = known.get(normalize_string(t['title']))
if repo and not app_repo(t):
t['description'] = f'{t["description"].rstrip()}\n\nSource: https://github.com/{repo}'
filled += 1
return filled
def deduplicate_and_normalize(templates, overrides=None):
"""Filter invalid, deduplicate by (title, type) keeping the highest-ranked, and normalize category names."""
overrides = overrides or {}
best = {}
for t in templates:
if not is_valid_template(t):
log.warning(f'Skipping invalid template: {t.get("title", "<no title>")}')
continue
problems = schema_errors(t)
if problems:
log.warning(f'Skipping template that fails the schema: {t["title"]} '
f'from {t["_source"]} ({problems[0]})')
continue
key = (normalize_string(t['title']), t.get('type', 1))
t_is_local = t.get('_local', False)
t_score = template_score(t)
if key in best:
existing = best[key]
existing_is_local = existing.get('_local', False)
existing_score = template_score(existing)
# Local always beats non-local; among same locality, higher score wins
if t_is_local and not existing_is_local:
best[key] = t
elif not t_is_local and existing_is_local:
pass # keep existing
elif t_score > existing_score:
best[key] = t
else:
pinned = pinned_source(overrides, key)
if key not in best or template_rank(t, pinned) > template_rank(best[key], pinned):
best[key] = t
result = []
for t in best.values():
@@ -254,10 +328,24 @@ def deduplicate_and_normalize(templates):
result.append(t)
return result
def category_key(label):
"""Key that ignores spacing and a plural 's', so near-identical spellings collapse."""
key = re.sub(r'[^a-z0-9]', '', label.lower())
return key[:-1] if key.endswith('s') and len(key) > 4 else key
def canonical_categories(templates):
"""One spelling per category, so 'Database' and 'Databases' don't split the listings."""
counts = Counter(c for t in templates for c in t.get('categories', []))
best = {}
for label in sorted(counts, key=lambda l: (-len(l.split()), -counts[l], l)):
best.setdefault(category_key(label), label)
for t in templates:
if t.get('categories'):
t['categories'] = list(dict.fromkeys(best[category_key(c)] for c in t['categories']))
return len(counts) - len(best)
def postfix_ambiguous_titles(templates):
"""Append type labels to titles that appear with multiple types."""
from collections import Counter
# Pass 1: postfix titles that share a normalized name across different types
title_counts = Counter(normalize_string(t['title']) for t in templates)
ambiguous = {title for title, count in title_counts.items() if count > 1}
@@ -278,27 +366,74 @@ def postfix_ambiguous_titles(templates):
label = TYPE_LABELS.get(tmpl_type, f'type{tmpl_type}')
t['title'] = f"{t['title']} ({label})"
def missing_sources():
"""Names from sources.csv with no downloaded file in sources/external/."""
with open(os.path.join(BASE_DIR, 'sources.csv')) as f:
expected = {row[0].strip() + '.json' for row in csv.reader(f)
if len(row) > 1 and row[0].strip() and row[1].strip()}
def audit_overrides(templates, overrides):
"""Warn about overrides that stopped earning their place, so the file doesn't go stale."""
groups = {}
for t in templates:
if is_valid_template(t):
groups.setdefault((normalize_string(t['title']), t.get('type', 1)), []).append(t)
for key, entry in sorted(overrides.items()):
where = f'{entry["title"]} (type {key[1]})'
candidates = groups.get(key)
if not candidates:
log.warning(f'Stale override: no template matches {where}')
continue
prefer = entry.get('prefer')
if not prefer:
continue
pinned = f'{prefer}.json'
if not any(t['_source'] == pinned for t in candidates):
log.warning(f'Ineffective override: {prefer} has no copy of {where}, using normal priority')
elif max(candidates, key=template_rank) is max(candidates, key=lambda t: template_rank(t, pinned)):
log.warning(f'Redundant override: normal priority already picks {prefer} for {where}')
def audit_app_sources(templates, sources):
"""Warn when a local copy shadows an app source, since the author's updates never publish"""
local = {}
for t in templates:
if t['_priority'] == 0 and isinstance(t.get('title'), str):
local.setdefault((normalize_string(t['title']), t.get('type', 1)), t['_source'])
app_files = {s.filename for s in sources if s.is_app}
for t in templates:
if t['_source'] not in app_files or not isinstance(t.get('title'), str):
continue
shadowed_by = local.get((normalize_string(t['title']), t.get('type', 1)))
if shadowed_by:
log.warning(f'{t["title"]} from the {t["_source"]} app source is shadowed by the '
f'copy in sources/local/{shadowed_by}, so the author\'s updates never '
'get published. Delete the local copy to let them through.')
def missing_sources(sources, kind):
"""Sources of one kind that sources.csv lists but nothing was downloaded for."""
external_dir = os.path.join(SOURCES_DIR, 'external')
present = set(os.listdir(external_dir)) if os.path.isdir(external_dir) else set()
return expected - present
return sorted(s.name for s in sources if s.kind == kind and s.filename not in present)
if __name__ == '__main__':
banner('Combine', 'Merge, normalize + dedupe template sources into templates.json')
raw = normalize_template_fields(load_sources())
overrides = load_overrides()
sources = sources_list.load()
raw = normalize_template_fields(load_sources(sources))
sources_count = len({t.get('_source') for t in raw})
log.info(f'Normalized {len(raw)} templates from {sources_count} sources')
templates = deduplicate_and_normalize(raw)
if any(s.is_app for s in sources):
audit_app_sources(raw, sources)
if overrides:
log.info(f'Applying {len(overrides)} template overrides')
audit_overrides(raw, overrides)
templates = deduplicate_and_normalize(raw, overrides)
log.info(f'{len(templates)} unique templates after dedup ({len(raw) - len(templates)} removed)')
merged = canonical_categories(templates)
if merged:
log.info(f'Merged {merged} duplicate category spellings')
filled = backfill_app_source(templates, raw)
if filled:
log.info(f'Restored the app source link on {filled} descriptions')
postfix_ambiguous_titles(templates)
# Strip internal tags
for t in templates:
t.pop('_source', None)
t.pop('_local', None)
t.pop('_priority', None)
templates.sort(key=lambda t: t['title'].lower())
for i, t in enumerate(templates, start=1):
t['id'] = i
@@ -308,10 +443,15 @@ if __name__ == '__main__':
previous = len(json.load(f)['templates'])
except (OSError, ValueError, KeyError):
previous = 0
# A failed source download must not silently shrink the published list
missing = missing_sources()
# A missing app source only costs its own app, so it's left out rather than fatal
unavailable = missing_sources(sources, sources_list.APP)
if unavailable:
log.warning(f'{len(unavailable)} app sources were not downloaded, so they are left '
f'out of this build: {", ".join(unavailable)}')
# A failed collection download must not silently shrink the published list
missing = missing_sources(sources, sources_list.COLLECTION)
if missing and not os.environ.get('ALLOW_SHRINK'):
log.error(f'Refusing to write: missing external sources: {", ".join(sorted(missing))}. '
log.error(f'Refusing to write: missing external sources: {", ".join(missing)}. '
'Set ALLOW_SHRINK=1 if this is intentional.')
sys.exit(1)
if len(templates) < previous * 0.9 and not os.environ.get('ALLOW_SHRINK'):
+34 -55
View File
@@ -1,10 +1,8 @@
import os
import csv
import sys
import time
import requests
import json
import sources_list
from log import get_logger, banner
log = get_logger()
@@ -12,75 +10,56 @@ log = get_logger()
dir = os.path.dirname(os.path.abspath(__file__))
destination_dir = os.path.join(dir, '../sources/external')
sources_list = os.path.join(dir, '../sources.csv')
# Downloads the templates file from a given URL, to the local destination
def download(url: str, filename: str, maintainer: str):
file_path = os.path.join(destination_dir, filename)
log.info(f'Downloading {url}')
sourceJson = None
for attempt in range(3):
try:
r = requests.get(url, timeout=30)
r.raise_for_status()
sourceJson = r.json()
break
except (requests.RequestException, ValueError) as err:
if attempt == 2:
log.warning(f'Skipping source due to an error: {url} ({err})')
return False
time.sleep(2 ** attempt)
# Downloads the templates file for a given source, to the local destination
def download(source):
log.info(f'Downloading {source.url}')
payload, error = sources_list.fetch_json(source.url)
if error:
log.warning(f'Skipping source due to an error: {source.url} ({error})')
return False
# Handle sources without valid data
if isinstance(sourceJson, list):
sourceJson = {'templates': sourceJson}
templates = sourceJson.get('templates') if isinstance(sourceJson, dict) else None
if not isinstance(templates, list) or not templates:
log.warning(f'Skipping source with no templates: {url}')
# An app source may publish a bare template object, since it's just the one
templates = sources_list.templates_in(payload, allow_bare=source.is_app)
if not templates:
log.warning(f'Skipping source with no templates: {source.url}')
return False
# 'app' means one author's own app, not a back door for an unreviewed list
if source.is_app and len(templates) > sources_list.MAX_APP_TEMPLATES:
log.warning(f'Skipping {source.name}: an app source may publish '
f'{sources_list.MAX_APP_TEMPLATES} template, but this one has '
f'{len(templates)}, so it belongs in sources.csv as a collection')
return False
# Add maintainer field to each template
for t in templates:
if isinstance(t, dict) and maintainer:
t['maintainer'] = maintainer
if isinstance(t, dict) and source.maintainer:
t['maintainer'] = source.maintainer
file_path = os.path.join(destination_dir, source.filename)
log.debug(f'Saving to {os.path.abspath(file_path)}')
with open(file_path, 'w') as f:
json.dump(sourceJson, f, indent=2, sort_keys=False)
json.dump({'templates': templates}, f, indent=2, sort_keys=False)
return True
# Gets list of URLs to download from CSV file
def get_source_list():
sources = []
seen = set()
with open(sources_list, mode='r') as file:
csvFile = csv.reader(file)
for lines in csvFile:
row = [col.strip() for col in lines]
if len(row) < 2 or not row[0] or not row[1]:
if any(row):
log.warning(f'Skipping malformed sources.csv row: {lines}')
continue
if row[0] in seen:
log.warning(f'Skipping duplicate source: {row[0]}')
continue
seen.add(row[0])
sources.append(row)
return sources
# Create destination folder if not yet present
if not os.path.exists(destination_dir):
os.makedirs(destination_dir)
banner('Download', 'Fetch template sources listed in sources.csv')
sources = get_source_list()
failures = []
for source in sources:
if not download(source[1], source[0] + '.json', source[2] if len(source) > 2 else ''):
failures.append(source[0])
sources = sources_list.load()
failures = [source for source in sources if not download(source)]
log.info(f'Downloaded {len(sources) - len(failures)}/{len(sources)} sources')
if failures:
log.error(f'Failed to download valid sources: {", ".join(failures)}')
# A lost collection stops the build; a lost app source only costs its own app
failed_apps = [source.name for source in failures if source.is_app]
failed_collections = [source.name for source in failures if not source.is_app]
if failed_apps:
log.warning(f'Leaving out {len(failed_apps)} unavailable app '
f'sources: {", ".join(failed_apps)}')
if failed_collections:
log.error(f'Failed to download valid sources: {", ".join(failed_collections)}')
sys.exit(1)
+53 -25
View File
@@ -2,10 +2,10 @@ import json
import html
import urllib.parse
import os
import csv
import re
import sys
import sources_list
from log import get_logger, banner
log = get_logger()
@@ -14,16 +14,12 @@ current_dir = os.path.dirname(os.path.abspath(__file__))
project_dir = os.path.dirname(current_dir)
readme_path = os.path.join(project_dir, '.github/README.md')
templates_path = os.path.join(project_dir, 'templates.json')
sources_path = os.path.join(project_dir, 'sources.csv')
external_dir = os.path.join(project_dir, 'sources/external')
def load_json_file(file_path):
with open(file_path, 'r') as file:
return json.load(file)
def load_csv_file(file_path):
with open(file_path, 'r') as file:
return list(csv.reader(file))
def slugify(title: str):
baseUrl = 'https://portainer-templates.as93.net'
slug = re.sub(r'\s+', '-', re.sub(r'[^a-z0-9 ]', '', title.lower()).strip())
@@ -50,25 +46,45 @@ def generate_app_list():
markdown_content += f"{index+1}. {logo}**[{name}]({slugify(name)} '{description}')** {maintainer_md_link}\n"
return markdown_content
def generate_sources_list():
sources = load_csv_file(sources_path)
markdown_content = ''
def github_user(*urls):
"""The GitHub account behind a source, for its avatar: from its url, else its maintainer"""
for url in urls:
parsed = urllib.parse.urlparse(url or '')
path_parts = [p for p in parsed.path.split('/') if p]
if parsed.hostname in ('github.com', 'raw.githubusercontent.com') and path_parts:
return path_parts[0]
return None
count = 0
for source in sources:
if len(source) > 1 and source[1].strip():
count += 1
url = source[1].strip()
parsed_url = urllib.parse.urlparse(url)
path_parts = [p for p in parsed_url.path.split('/') if p]
if parsed_url.hostname in ('github.com', 'raw.githubusercontent.com') and path_parts:
username = path_parts[0]
avatar = f'<img src="https://github.com/{username}.png?size=40" width="26" height="26" />'
markdown_content += f"{count}. {avatar} [template]({url}) by [@{username}](https://github.com/{username})\n"
else:
markdown_content += f"{count}. [template]({url})\n"
def source_credit(source, label):
"""One line of a README credit list: avatar, link and the author's handle"""
username = github_user(source.url, source.maintainer)
if not username:
return f'[{label}]({source.url})'
avatar = f'<img src="https://github.com/{username}.png?size=40" width="26" height="26" />'
return (f'{avatar} [{label}]({source.url}) by '
f'[@{username}](https://github.com/{username})')
return markdown_content
def app_label(source):
"""An app's own title from what was downloaded, so the credit reads Cantinarr not cantinarr"""
try:
templates = load_json_file(os.path.join(external_dir, source.filename))['templates']
titles = [t['title'] for t in templates if isinstance(t, dict) and t.get('title')]
except (OSError, ValueError, KeyError):
titles = []
return titles[0] if titles else source.name
def generate_sources_list(sources):
"""The collections: other people's maintained lists of templates"""
return ''.join(f'{index}. {source_credit(source, "template")}\n'
for index, source in enumerate(sources, start=1))
def generate_app_sources_list(sources):
"""The individual apps, each published and kept up to date by its own author"""
if not sources:
return ('_None yet - if you maintain a self-hosted app, '
'[add yours](CONTRIBUTING.md#option-1-publish-your-own-template-recommended)._')
return ''.join(f'{index}. {source_credit(source, app_label(source))}\n'
for index, source in enumerate(sources, start=1))
def insert_content_between_markers(file_path, start_marker, end_marker, content_to_insert):
with open(file_path, 'r') as file:
@@ -95,8 +111,10 @@ def insert_content_between_markers(file_path, start_marker, end_marker, content_
banner('List', 'Render app + source lists into the README')
# Insert sources list into readme
sources_md = generate_sources_list()
all_sources = sources_list.load()
# Insert the collection sources list into readme
sources_md = generate_sources_list([s for s in all_sources if not s.is_app])
insert_content_between_markers(
readme_path,
'<!-- auto-insert-sources:start -->',
@@ -105,6 +123,16 @@ insert_content_between_markers(
)
log.info(f'Rendered {sources_md.count(chr(10))} sources into README')
# Insert the individual app sources list into readme
app_sources = [s for s in all_sources if s.is_app]
insert_content_between_markers(
readme_path,
'<!-- auto-insert-app-sources:start -->',
'<!-- auto-insert-app-sources:end -->',
generate_app_sources_list(app_sources),
)
log.info(f'Rendered {len(app_sources)} individual app sources into README')
# Insert app list into readme
apps_md = generate_app_list()
insert_content_between_markers(
+113
View File
@@ -0,0 +1,113 @@
"""Reads sources.csv (name, url, maintainer, kind) and fetches what it points at."""
import csv
import os
import re
import time
from typing import NamedTuple
from urllib.parse import urlparse
from log import get_logger
log = get_logger()
SOURCES_CSV = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'sources.csv')
# 'collection' is a list of many apps, 'app' is one app published by its own author
COLLECTION = 'collection'
APP = 'app'
KINDS = (COLLECTION, APP)
# A source name becomes a file name, so keep it to something unambiguous
NAME_PATTERN = re.compile(r'^[a-z0-9][a-z0-9_-]*$')
# One app, one template: an approved row must not be able to grow entries nobody reviewed
MAX_APP_TEMPLATES = 1
class Source(NamedTuple):
"""One usable row of sources.csv"""
name: str
url: str
maintainer: str
kind: str
@property
def filename(self):
"""Where download.py saves it, and the name combine.py ranks it by"""
return f'{self.name}.json'
@property
def is_app(self):
return self.kind == APP
def rows(path=SOURCES_CSV):
"""Every non-blank row, as (line number, stripped cells)"""
with open(path, newline='') as file:
for line, row in enumerate(csv.reader(file), start=1):
cells = [cell.strip() for cell in row]
if any(cells):
yield line, cells
def row_problems(cells):
"""Everything wrong with a row, as (level, message) pairs; any problem makes load() skip it"""
if len(cells) < 2 or not cells[0] or not cells[1]:
return [('warning', f'malformed row, would be skipped: {cells}')]
problems = []
if not NAME_PATTERN.match(cells[0]):
problems.append(('error', f'name {cells[0]!r} becomes a file name, so it may only hold '
"lowercase letters, digits, '_' and '-'"))
if urlparse(cells[1]).scheme not in ('http', 'https'):
problems.append(('error', f'{cells[0]} has a non-http(s) url: {cells[1]}'))
kind = cells[3] if len(cells) > 3 else ''
if kind and kind.lower() not in KINDS:
problems.append(('error', f'{cells[0]} has kind {kind!r}, expected '
f'{" or ".join(KINDS)}'))
if len(cells) > 4 and any(cells[4:]):
problems.append(('error', f'{cells[0]} has {len(cells)} columns, expected at most 4 '
'(name, url, maintainer, kind)'))
return problems
def parse(cells):
"""A Source from a row already known to be well-formed"""
kind = cells[3].lower() if len(cells) > 3 and cells[3] else COLLECTION
maintainer = cells[2] if len(cells) > 2 else ''
return Source(cells[0], cells[1], maintainer, kind)
def load(path=SOURCES_CSV):
"""Every usable source, in listed order. validate_sources.py reports the rows skipped here"""
sources, seen = [], set()
for line, cells in rows(path):
problems = row_problems(cells)
if problems:
for _, message in problems:
log.warning(f'Skipping sources.csv:{line}: {message}')
continue
if cells[0] in seen:
log.warning(f'Skipping sources.csv:{line}: duplicate source {cells[0]!r}')
continue
seen.add(cells[0])
sources.append(parse(cells))
return sources
def fetch_json(url, attempts=3):
"""Fetch and parse JSON, retrying on a blip. Returns (payload, error), one of them None"""
import requests # only the fetching path needs it
for attempt in range(attempts):
try:
response = requests.get(url, timeout=30)
response.raise_for_status()
return response.json(), None
except (requests.RequestException, ValueError) as err:
if attempt == attempts - 1:
return None, str(err)
time.sleep(2 ** attempt)
def templates_in(payload, allow_bare=False):
"""The template list in a downloaded source, or None. `allow_bare` takes a lone object"""
if isinstance(payload, list):
return payload
if isinstance(payload, dict):
if isinstance(payload.get('templates'), list):
return payload['templates']
if allow_bare and isinstance(payload.get('title'), str):
return [payload]
return None
+135 -37
View File
@@ -1,14 +1,13 @@
import copy
import csv
import json
import os
import sys
from collections import Counter
from urllib.parse import urlparse
from jsonschema import Draft7Validator, FormatChecker
from combine import normalize_template
import sources_list
from combine import normalize_template, normalize_string
from log import get_logger, banner
log = get_logger()
@@ -21,6 +20,7 @@ VALIDATOR = Draft7Validator(ITEM_SCHEMA, format_checker=FormatChecker())
# Absence of these only warrants a warning under the Moderate quality gate
RECOMMENDED = ['logo', 'categories', 'note', 'platform', 'restart_policy']
SOURCE_EXTENSIONS = ('.json', '.yml', '.yaml', '.csv')
OVERRIDE_STATUSES = ('ok', 'unmaintained', 'broken')
def load_json(path):
with open(path) as file:
@@ -43,19 +43,9 @@ def quality_warnings(where, template):
for name, count in Counter(names).items() if count > 1]
return warnings
def check_source(path):
"""Validate one local source: each template must normalize to a schema-valid entry"""
try:
data = load_json(path)
except (OSError, json.JSONDecodeError) as err:
return [f'{path}: could not read JSON ({err})'], []
templates = data.get('templates') if isinstance(data, dict) else None
if not isinstance(templates, list):
return [f'{path}: missing top-level "templates" array'], []
name = os.path.basename(path)
errors, warnings, titles = [], [], Counter()
def check_templates(name, templates):
"""Validate a list of templates: each must normalize to a schema-valid entry"""
errors, warnings, seen = [], [], Counter()
if not templates:
warnings.append(f'{name}: contains no templates')
for index, raw in enumerate(templates):
@@ -65,7 +55,6 @@ def check_source(path):
continue
title = raw.get('title')
if isinstance(title, str) and title.strip():
titles[title.strip()] += 1
where = f'{name} "{title.strip()}"'
# Normalize a copy exactly as combine.py would, then judge the published result
try:
@@ -78,11 +67,54 @@ def check_source(path):
errors += [f'{where}: {e.message}' for e in VALIDATOR.iter_errors(template)]
errors += stackfile_errors(where, template)
warnings += quality_warnings(where, template)
# Keyed like combine's dedup, so one app may ship both a container and a stack
if isinstance(title, str) and title.strip():
seen[(normalize_string(title), template.get('type', 1))] += 1
errors += [f'{name}: duplicate title {title!r} ({count} templates)'
for title, count in titles.items() if count > 1]
errors += [f'{name}: duplicate template {title!r} of type {kind} ({count} copies), '
'so all but one would be dropped'
for (title, kind), count in seen.items() if count > 1]
return errors, warnings
def check_source(path):
"""Validate one local source: each template must normalize to a schema-valid entry"""
try:
data = load_json(path)
except (OSError, json.JSONDecodeError) as err:
return [f'{path}: could not read JSON ({err})'], []
templates = data.get('templates') if isinstance(data, dict) else None
if not isinstance(templates, list):
return [f'{path}: missing top-level "templates" array'], []
return check_templates(os.path.basename(path), templates)
def url_kind(url):
"""The kind sources.csv gives this url, defaulting to app, the stricter of the two"""
for source in sources_list.load():
if source.url == url:
return source.kind
return sources_list.APP
def check_url(url):
"""Check a remote source as download.py will: an app per template, a collection on shape"""
payload, error = sources_list.fetch_json(url)
if error:
return [f'{url}: could not fetch valid JSON ({error})'], []
is_app = url_kind(url) == sources_list.APP
templates = sources_list.templates_in(payload, allow_bare=is_app)
if not templates:
shape = ('one template as a JSON object, or several under a top-level "templates" '
'array') if is_app else 'its templates under a top-level "templates" array'
return [f'{url}: found no templates, so the build would skip it. Publish {shape}'], []
if not is_app:
return [], [] # the shape is all the build needs from a collection
if len(templates) > sources_list.MAX_APP_TEMPLATES:
return [f'{url}: holds {len(templates)} templates, but an app source may publish only '
f'{sources_list.MAX_APP_TEMPLATES}, so it has to be listed as a collection'], []
return check_templates(url, templates)
def check_stack(path):
"""A compose stack file must be valid YAML with a non-empty services mapping"""
try:
@@ -99,32 +131,95 @@ def check_stack(path):
warnings = [f'{path}: obsolete top-level "version" key'] if 'version' in doc else []
return [], warnings
def local_source_names():
"""File names in sources/local/, which a source name can't reuse: combine keys on them"""
local_dir = os.path.join(ROOT, 'sources', 'local')
if not os.path.isdir(local_dir):
return set()
return {os.path.splitext(f)[0] for f in os.listdir(local_dir) if f.endswith('.json')}
def check_csv(path):
"""Validate sources.csv: each row needs a unique name and an http(s) url"""
"""Validate sources.csv: unique file-name-safe name, http(s) url, recognized kind"""
try:
with open(path, newline='') as file:
rows = list(csv.reader(file))
rows = list(sources_list.rows(path))
except OSError as err:
return [f'{path}: could not read ({err})'], []
local_names = local_source_names()
errors, warnings, seen = [], [], set()
for line, row in enumerate(rows, start=1):
cells = [cell.strip() for cell in row]
if not any(cells):
for line, cells in rows:
for level, message in sources_list.row_problems(cells):
(errors if level == 'error' else warnings).append(f'{path}:{line}: {message}')
name = cells[0]
if not name:
continue
if len(cells) < 2 or not cells[0] or not cells[1]:
warnings.append(f'{path}:{line}: malformed row, would be skipped: {row}')
continue
name, url = cells[0], cells[1]
if name in seen:
errors.append(f'{path}:{line}: duplicate source name {name!r}')
seen.add(name)
if urlparse(url).scheme not in ('http', 'https'):
errors.append(f'{path}:{line}: {name} has a non-http(s) url: {url}')
if name in local_names:
errors.append(f'{path}:{line}: name {name!r} collides with '
f'sources/local/{name}.json, which would leave the two '
'indistinguishable when picking between duplicate templates')
return errors, warnings
def source_names():
"""Source names from sources.csv, used to check an override's "prefer" target exists"""
try:
return {source.name for source in sources_list.load()}
except OSError:
return set()
def check_overrides(path):
"""Validate overrides.json: each entry must name a real template and actually do something"""
try:
data = load_json(path)
except (OSError, json.JSONDecodeError) as err:
return [f'{path}: could not read JSON ({err})'], []
entries = data.get('overrides') if isinstance(data, dict) else None
if not isinstance(entries, list):
return [f'{path}: missing top-level "overrides" array'], []
name = os.path.basename(path)
known = source_names()
errors, warnings, keys = [], [], Counter()
for index, entry in enumerate(entries):
where = f'{name}#{index}'
if not isinstance(entry, dict):
errors.append(f'{where}: override is not an object')
continue
title = entry.get('title')
if not (isinstance(title, str) and title.strip()):
errors.append(f'{where}: missing "title"')
continue
where = f'{name} "{title.strip()}"'
template_type = entry.get('type', 1)
if not isinstance(template_type, int) or template_type not in (1, 2, 3, 4):
errors.append(f'{where}: "type" must be 1, 2, 3 or 4')
continue
keys[(normalize_string(title), template_type)] += 1
prefer = entry.get('prefer')
if prefer is not None and prefer not in known:
errors.append(f'{where}: "prefer" names an unknown source {prefer!r}')
status = entry.get('status')
if status is not None and status not in OVERRIDE_STATUSES:
errors.append(f'{where}: "status" must be one of {", ".join(OVERRIDE_STATUSES)}')
if not any(entry.get(key) for key in ('prefer', 'status', 'note')):
warnings.append(f'{where}: has no "prefer", "status" or "note", so it does nothing')
errors += [f'{name}: duplicate override for {title!r} (type {kind}, {count} entries)'
for (title, kind), count in keys.items() if count > 1]
return errors, warnings
def is_url(target):
return target.startswith(('http://', 'https://'))
def validate_path(path):
"""Dispatch a path to the right checker by extension"""
"""Dispatch a target to the right checker, by url then file name then extension"""
if is_url(path):
return check_url(path)
if os.path.basename(path) == 'overrides.json':
return check_overrides(path)
if path.endswith('.csv'):
return check_csv(path)
if path.endswith(('.yml', '.yaml')):
@@ -135,7 +230,9 @@ def expand(targets):
"""Turn any directory argument into the source files it contains (recursively)"""
paths = []
for target in targets:
if os.path.isdir(target):
if is_url(target):
paths.append(target)
elif os.path.isdir(target):
for root, _, names in os.walk(target):
paths += [os.path.join(root, n) for n in names if n.endswith(SOURCE_EXTENSIONS)]
else:
@@ -143,19 +240,20 @@ def expand(targets):
return sorted(paths)
def main():
banner('Validate sources', 'Check template sources, stack files + sources.csv')
banner('Validate sources', 'Check template sources, stack files, sources.csv + overrides')
targets = sys.argv[1:] or [os.path.join(ROOT, p) for p in
('sources/local', 'sources/stacks', 'sources.csv')]
('sources/local', 'sources/stacks', 'sources.csv', 'overrides.json')]
errors, warnings = [], []
for path in expand(targets):
if not os.path.exists(path):
if not is_url(path) and not os.path.exists(path):
log.warning(f'Skipping missing path: {path}')
continue
file_errors, file_warnings = validate_path(path)
errors += file_errors
warnings += file_warnings
log.info(f'Checked {os.path.relpath(path, ROOT)}: '
where = path if is_url(path) else os.path.relpath(path, ROOT)
log.info(f'Checked {where}: '
f'{len(file_errors)} errors, {len(file_warnings)} warnings')
for warning in warnings:
+34
View File
@@ -0,0 +1,34 @@
{
"overrides": [
{
"title": "Elasticsearch",
"status": "broken",
"note": "No source has a working image. Portainer's copy pins 7.15.1 from 2021, and every other copy uses the `elasticsearch:latest` tag, which upstream does not publish. Elasticsearch ships no rolling tag at all, so a version has to be pinned and bumped by hand. Maintained in sources/local/development_templates.json.",
"updated": "2026-09-23"
},
{
"title": "Jenkins",
"prefer": "mikestraney_templates",
"reason": "Portainer's copy pins the retired lts-jdk11 tag; this one tracks the current LTS line",
"updated": "2026-09-23"
},
{
"title": "MySQL",
"prefer": "mikestraney_templates",
"reason": "Portainer's copy uses mysql/mysql-server:5.7, a repository Oracle retired on a release that reached end of life in October 2023",
"updated": "2026-09-23"
},
{
"title": "ruTorrent",
"prefer": "novaspirit_templates",
"reason": "Every other source uses linuxserver/rutorrent, which LinuxServer deprecated and last built in 2021",
"updated": "2026-09-23"
},
{
"title": "TiddlyWiki",
"prefer": "novaspirit_templates",
"reason": "Every other source uses mazzolino/tiddlywiki, last built 2020; nicolaw's is still maintained",
"updated": "2026-09-23"
}
]
}
+20 -10
View File
@@ -1,10 +1,20 @@
dnburgess_templates, https://raw.githubusercontent.com/dnburgess/self-hosted-template/master/template.json, https://github.com/dnburgess/self-hosted-template/
qballjos_templates, https://raw.githubusercontent.com/Qballjos/portainer_templates/master/Template/template.json, https://github.com/Qballjos/portainer_templates/
selfhostedpro_templates, https://raw.githubusercontent.com/SelfhostedPro/selfhosted_templates/portainer-2.0/Template/template.json, https://github.com/SelfhostedPro/selfhosted_templates/
technorabilia_templates, https://raw.githubusercontent.com/technorabilia/portainer-templates/main/lsio/templates/templates.json, https://github.com/technorabilia/portainer-templates/
mikestraney_templates, https://raw.githubusercontent.com/mikestraney/portainer-templates/master/templates.json, https://github.com/mikestraney/portainer-templates/
xneo1_templates, https://raw.githubusercontent.com/xneo1/portainer_templates/master/Template/template.json, https://github.com/xneo1/portainer_templates/
novaspirit_templates, https://raw.githubusercontent.com/novaspirit/pi-hosted/master/pi-hosted_template/template/portainer-v2.json, https://github.com/novaspirit/pi-hosted/
mediadepot_templates, https://raw.githubusercontent.com/mediadepot/templates/master/portainer.json, https://github.com/mediadepot/templates/
shmolf_templates, https://raw.githubusercontent.com/shmolf/portainer-templates/main/templates-2.0.json, https://github.com/shmolf/portainer-templates/
portainer_templates, https://raw.githubusercontent.com/portainer/templates/v3/templates.json, https://github.com/portainer/templates/
portainer_templates, https://raw.githubusercontent.com/portainer/templates/v3/templates.json, https://github.com/portainer/templates/, collection
technorabilia_templates, https://raw.githubusercontent.com/technorabilia/portainer-templates/main/lsio/templates/templates.json, https://github.com/technorabilia/portainer-templates/, collection
xneo1_templates, https://raw.githubusercontent.com/xneo1/portainer_templates/master/Template/template.json, https://github.com/xneo1/portainer_templates/, collection
mediadepot_templates, https://raw.githubusercontent.com/mediadepot/templates/master/portainer.json, https://github.com/mediadepot/templates/, collection
novaspirit_templates, https://raw.githubusercontent.com/novaspirit/pi-hosted/master/pi-hosted_template/template/portainer-v2.json, https://github.com/novaspirit/pi-hosted/, collection
qballjos_templates, https://raw.githubusercontent.com/Qballjos/portainer_templates/master/Template/template.json, https://github.com/Qballjos/portainer_templates/, collection
shmolf_templates, https://raw.githubusercontent.com/shmolf/portainer-templates/main/templates-2.0.json, https://github.com/shmolf/portainer-templates/, collection
mikestraney_templates, https://raw.githubusercontent.com/mikestraney/portainer-templates/master/templates.json, https://github.com/mikestraney/portainer-templates/, collection
selfhostedpro_templates, https://raw.githubusercontent.com/SelfhostedPro/selfhosted_templates/portainer-2.0/Template/template.json, https://github.com/SelfhostedPro/selfhosted_templates/, collection
dnburgess_templates, https://raw.githubusercontent.com/dnburgess/self-hosted-template/master/template.json, https://github.com/dnburgess/self-hosted-template/, collection
deployable_sh_templates, https://deployable.sh/portainer/templates.json, https://github.com/deployable-sh/stacks/, collection
notaflightrisk_templates, https://raw.githubusercontent.com/NotAFlightRisk/portainer-templates-extras/main/templates.json, https://github.com/NotAFlightRisk/portainer-templates-extras/, collection
homedex, https://raw.githubusercontent.com/HarshShah0203/homedex/main/portainer-template.json, https://github.com/HarshShah0203/homedex/, app
replayhaven, https://raw.githubusercontent.com/SauerExe/ReplayHaven/main/portainer-template.json, https://github.com/SauerExe/ReplayHaven/, app
ai-marketplace-monitor, https://raw.githubusercontent.com/BoPeng/ai-marketplace-monitor/main/deploy/portainer/templates.json, https://github.com/BoPeng/ai-marketplace-monitor/, app
libredb_studio, https://raw.githubusercontent.com/libredb/libredb-studio/main/portainer-template.json, https://github.com/libredb/libredb-studio/, app
seawise_client, https://raw.githubusercontent.com/seawise-io/seawise-client/main/portainer-template.json, https://github.com/seawise-io/seawise-client/, app
opentodo, https://raw.githubusercontent.com/OpenTodoApp/OpenTodo/main/deploy/portainer/portainer-template.json, https://github.com/OpenTodoApp/OpenTodo/, app
armanckeser_selfhost_apps, https://armanckeser.github.io/selfhost-apps/portainer/templates.json, https://github.com/armanckeser/selfhost-apps/, collection
yuvomi, https://raw.githubusercontent.com/ulsklyc/yuvomi/main/deploy/portainer/portainer-template.json, https://github.com/ulsklyc/yuvomi/, app
1 dnburgess_templates portainer_templates https://raw.githubusercontent.com/dnburgess/self-hosted-template/master/template.json https://raw.githubusercontent.com/portainer/templates/v3/templates.json https://github.com/dnburgess/self-hosted-template/ https://github.com/portainer/templates/ collection
2 qballjos_templates technorabilia_templates https://raw.githubusercontent.com/Qballjos/portainer_templates/master/Template/template.json https://raw.githubusercontent.com/technorabilia/portainer-templates/main/lsio/templates/templates.json https://github.com/Qballjos/portainer_templates/ https://github.com/technorabilia/portainer-templates/ collection
3 selfhostedpro_templates xneo1_templates https://raw.githubusercontent.com/SelfhostedPro/selfhosted_templates/portainer-2.0/Template/template.json https://raw.githubusercontent.com/xneo1/portainer_templates/master/Template/template.json https://github.com/SelfhostedPro/selfhosted_templates/ https://github.com/xneo1/portainer_templates/ collection
4 technorabilia_templates mediadepot_templates https://raw.githubusercontent.com/technorabilia/portainer-templates/main/lsio/templates/templates.json https://raw.githubusercontent.com/mediadepot/templates/master/portainer.json https://github.com/technorabilia/portainer-templates/ https://github.com/mediadepot/templates/ collection
5 mikestraney_templates novaspirit_templates https://raw.githubusercontent.com/mikestraney/portainer-templates/master/templates.json https://raw.githubusercontent.com/novaspirit/pi-hosted/master/pi-hosted_template/template/portainer-v2.json https://github.com/mikestraney/portainer-templates/ https://github.com/novaspirit/pi-hosted/ collection
6 xneo1_templates qballjos_templates https://raw.githubusercontent.com/xneo1/portainer_templates/master/Template/template.json https://raw.githubusercontent.com/Qballjos/portainer_templates/master/Template/template.json https://github.com/xneo1/portainer_templates/ https://github.com/Qballjos/portainer_templates/ collection
7 novaspirit_templates shmolf_templates https://raw.githubusercontent.com/novaspirit/pi-hosted/master/pi-hosted_template/template/portainer-v2.json https://raw.githubusercontent.com/shmolf/portainer-templates/main/templates-2.0.json https://github.com/novaspirit/pi-hosted/ https://github.com/shmolf/portainer-templates/ collection
8 mediadepot_templates mikestraney_templates https://raw.githubusercontent.com/mediadepot/templates/master/portainer.json https://raw.githubusercontent.com/mikestraney/portainer-templates/master/templates.json https://github.com/mediadepot/templates/ https://github.com/mikestraney/portainer-templates/ collection
9 shmolf_templates selfhostedpro_templates https://raw.githubusercontent.com/shmolf/portainer-templates/main/templates-2.0.json https://raw.githubusercontent.com/SelfhostedPro/selfhosted_templates/portainer-2.0/Template/template.json https://github.com/shmolf/portainer-templates/ https://github.com/SelfhostedPro/selfhosted_templates/ collection
10 portainer_templates dnburgess_templates https://raw.githubusercontent.com/portainer/templates/v3/templates.json https://raw.githubusercontent.com/dnburgess/self-hosted-template/master/template.json https://github.com/portainer/templates/ https://github.com/dnburgess/self-hosted-template/ collection
11 deployable_sh_templates https://deployable.sh/portainer/templates.json https://github.com/deployable-sh/stacks/ collection
12 notaflightrisk_templates https://raw.githubusercontent.com/NotAFlightRisk/portainer-templates-extras/main/templates.json https://github.com/NotAFlightRisk/portainer-templates-extras/ collection
13 homedex https://raw.githubusercontent.com/HarshShah0203/homedex/main/portainer-template.json https://github.com/HarshShah0203/homedex/ app
14 replayhaven https://raw.githubusercontent.com/SauerExe/ReplayHaven/main/portainer-template.json https://github.com/SauerExe/ReplayHaven/ app
15 ai-marketplace-monitor https://raw.githubusercontent.com/BoPeng/ai-marketplace-monitor/main/deploy/portainer/templates.json https://github.com/BoPeng/ai-marketplace-monitor/ app
16 libredb_studio https://raw.githubusercontent.com/libredb/libredb-studio/main/portainer-template.json https://github.com/libredb/libredb-studio/ app
17 seawise_client https://raw.githubusercontent.com/seawise-io/seawise-client/main/portainer-template.json https://github.com/seawise-io/seawise-client/ app
18 opentodo https://raw.githubusercontent.com/OpenTodoApp/OpenTodo/main/deploy/portainer/portainer-template.json https://github.com/OpenTodoApp/OpenTodo/ app
19 armanckeser_selfhost_apps https://armanckeser.github.io/selfhost-apps/portainer/templates.json https://github.com/armanckeser/selfhost-apps/ collection
20 yuvomi https://raw.githubusercontent.com/ulsklyc/yuvomi/main/deploy/portainer/portainer-template.json https://github.com/ulsklyc/yuvomi/ app
+40 -5
View File
@@ -45,28 +45,63 @@
"description": "Self-hostable AI config management platform for AGENTS.md, CLAUDE.md, Cursor rules, and 30+ AI assistant formats. Source: https://github.com/GeiserX/LynxPrompt",
"env": [
{
"default": "",
"label": "Session secret \u2014 generate with: openssl rand -base64 32",
"description": "Generate with: openssl rand -hex 32",
"label": "Session secret (required)",
"name": "NEXTAUTH_SECRET"
},
{
"default": "http://localhost:3000",
"label": "Public base URL",
"description": "Generate with: openssl rand -hex 24",
"label": "Database password (required)",
"name": "DB_PASSWORD"
},
{
"description": "Such as http://192.168.1.10:3000 or https://lynxprompt.example.com",
"label": "Address you open it on (required)",
"name": "APP_URL"
},
{
"description": "Sign-in is a link sent by mail",
"label": "SMTP server (required)",
"name": "SMTP_HOST"
},
{
"description": "From address of the sign-in mails",
"label": "Sender address (required)",
"name": "SMTP_FROM"
},
{
"default": "587",
"description": "465 uses TLS",
"label": "SMTP port",
"name": "SMTP_PORT"
},
{
"default": "",
"label": "Super-admin account email",
"description": "Leave empty if the server needs no login",
"label": "SMTP user",
"name": "SMTP_USER"
},
{
"default": "",
"label": "SMTP password",
"name": "SMTP_PASSWORD"
},
{
"default": "",
"description": "This address becomes super-admin on its first sign-in",
"label": "Super-admin email",
"name": "SUPERADMIN_EMAIL"
}
],
"logo": "https://avatars.githubusercontent.com/u/9169332",
"name": "lynxprompt",
"note": "LynxPrompt listens on port 3000 and keeps its database in the pgdata volume and uploaded images in the uploads volume. Sign-in is a link sent by mail, so fill in the SMTP fields, and set APP_URL to the address you open it on. Generate the secrets with openssl rand -hex 32 and openssl rand -hex 24. All options: https://github.com/GeiserX/LynxPrompt/blob/main/env.example",
"platform": "linux",
"repository": {
"stackfile": "sources/stacks/lynxprompt.yml",
"url": "https://github.com/lissy93/portainer-templates"
},
"restart_policy": "unless-stopped",
"title": "LynxPrompt",
"type": 3
}
+35
View File
@@ -153,6 +153,41 @@
"restart_policy": "unless-stopped",
"title": "Gotenberg",
"type": 1
},
{
"categories": [
"Database"
],
"description": "Open-source search and analytics engine",
"env": [
{
"default": "single-node",
"name": "discovery.type",
"preset": true
},
{
"default": "false",
"label": "Disable X-Pack security",
"name": "xpack.security.enabled"
}
],
"image": "elasticsearch:9.5.3",
"logo": "https://portainer-io-assets.sfo2.digitaloceanspaces.com/logos/elasticsearch.png",
"name": "elasticsearch",
"note": "Elasticsearch publishes no rolling <code>latest</code> tag, so this template pins a specific version. <code>discovery.type</code> and <code>xpack.security.enabled</code> are set so a single-node container starts and stays reachable without TLS setup.",
"platform": "linux",
"ports": [
"9200/tcp",
"9300/tcp"
],
"restart_policy": "unless-stopped",
"title": "Elasticsearch",
"type": 1,
"volumes": [
{
"container": "/usr/share/elasticsearch/data"
}
]
}
],
"version": "3"
+3 -3
View File
@@ -275,10 +275,10 @@
"Media",
"Tools"
],
"description": "One app over your whole media stack: household members browse and request movies, TV, books, and music, and admins get Radarr, Sonarr, Chaptarr, and Lidarr control down to the individual episode, live queues for SABnzbd, qBittorrent, NZBGet, and Transmission, Tautulli activity, Plex invites, plain-English fixes for stuck downloads, and an MCP server for Claude or any MCP client. GitHub: [windoze95/cantinarr](https://github.com/windoze95/cantinarr)",
"description": "Media requests and server management in one app. Household members browse and request movies, shows, books, and music, follow downloads, and get notified when content is ready. Administrators manage Radarr, Sonarr, Chaptarr, Lidarr, and download clients, review approvals, and investigate stuck downloads. AI is optional, and authorized MCP clients can use the same server. GitHub: [windoze95/cantinarr](https://github.com/windoze95/cantinarr)",
"env": [
{
"default": "https://push.julian.codes",
"default": "https://push.cantinarr.com",
"description": "Push gateway for the companion iOS and Android apps. The server enrolls itself on first start; clear to disable push, or point it at your own gateway.",
"label": "CANTINARR_PUSH_GATEWAY_URL",
"name": "CANTINARR_PUSH_GATEWAY_URL"
@@ -291,7 +291,7 @@
}
],
"image": "ghcr.io/windoze95/cantinarr:latest",
"logo": "https://raw.githubusercontent.com/windoze95/cantinarr/main/app/web/icons/Icon-512.png",
"logo": "https://cantinarr.com/static/symbol-transparent-v1.png",
"name": "cantinarr",
"note": "Web UI and API on port 8585. Radarr, Sonarr, Chaptarr, download clients, Tautulli, and Plex are all optional and are added from the admin UI after first run. Documentation: https://github.com/windoze95/cantinarr",
"platform": "linux",
+36
View File
@@ -111,6 +111,42 @@
"container": "/config"
}
]
},
{
"categories": [
"Monitoring"
],
"description": "Uptime and synthetic monitoring with status pages, incidents and alerts. Checks HTTP, DNS, TCP, ICMP, TLS expiry, databases, message brokers, heartbeats and headless browser scenarios. Source: https://github.com/fclairamb/solidping",
"env": [
{
"name": "SP_NODE_NAME",
"label": "Node name",
"default": "solidping",
"description": "Worker name, lowercase letters, digits and dashes, starting with a letter (3 to 21 characters). The server refuses to start without a valid one."
},
{
"name": "SP_BASE_URL",
"label": "Public URL",
"default": "http://localhost:4000",
"description": "URL people use to reach SolidPing. Used in links and notifications."
}
],
"image": "ghcr.io/fclairamb/solidping:latest",
"logo": "https://raw.githubusercontent.com/fclairamb/solidping/main/res/logo_256.png",
"name": "solidping",
"note": "Open <code>http://&lt;your-host&gt;:4000</code>. First login: <code>admin@solidping.io</code> / <code>solidpass</code>, organization <code>default</code>. You are asked to set a new password right away. The SQLite database and uploads live in the <code>/data</code> volume. No secret is needed to start. Configuration: <a href=\"https://docs.solidping.io/configuration/\">docs.solidping.io</a>",
"platform": "linux",
"ports": [
"4000:4000/tcp"
],
"restart_policy": "unless-stopped",
"title": "SolidPing",
"type": 1,
"volumes": [
{
"container": "/data"
}
]
}
],
"version": "3"
+19 -1
View File
@@ -294,7 +294,7 @@
"Tools",
"Networking"
],
"description": "Open-source fuel and EV route planner with real-time prices across 36 countries. Source: https://github.com/GeiserX/Pumperly",
"description": "Open-source fuel and EV route planner with live prices from 22 countries. Source: https://github.com/GeiserX/Pumperly",
"env": [
{
"default": "ES",
@@ -384,6 +384,24 @@
"container": "/app/uploads"
}
]
},
{
"categories": [
"Productivity",
"Documents"
],
"description": "Collaborative LaTeX editor you host yourself, an alternative to Overleaf. Real-time editing, a git repository per project, and a sandboxed TeX Live compiler. Source: https://github.com/trahloff/Aldine",
"logo": "https://raw.githubusercontent.com/trahloff/Aldine/main/brand/icon.svg",
"name": "aldine",
"note": "Open <code>http://&lt;your-host&gt;:8080</code>. The stack has two containers: the app, and a compiler that runs TeX Live on an internal network with no internet access, all Linux capabilities dropped and bounded memory and processes. The compiler image is about 2.4 GB to download, so the first deploy takes a while. There is no login by default, so anyone who can reach the port can open every project; to require accounts, edit the stack and add <code>AUTH_ENABLED: \"1\"</code> under the app service's <code>environment</code>. Docs: <a href=\"https://github.com/trahloff/Aldine/blob/main/deploy/README.md\" target=\"_blank\">deploy/README.md</a>",
"platform": "linux",
"repository": {
"stackfile": "docker-compose.yml",
"url": "https://github.com/trahloff/Aldine"
},
"restart_policy": "unless-stopped",
"title": "Aldine",
"type": 3
}
],
"version": "3"
+52 -38
View File
@@ -1,46 +1,60 @@
services:
postgres:
image: postgres:18-alpine
restart: unless-stopped
volumes:
# Postgres 18 refuses a mount at /var/lib/postgresql/data.
- pgdata:/var/lib/postgresql
environment:
POSTGRES_DB: lynxprompt
POSTGRES_USER: lynxprompt
POSTGRES_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD must be set}
healthcheck:
test: ["CMD-SHELL", "pg_isready -U lynxprompt -d lynxprompt"]
interval: 5s
timeout: 5s
retries: 5
lynxprompt:
image: drumsergio/lynxprompt:2.1.0
image: drumsergio/lynxprompt:2.1.40
platform: linux/amd64
restart: unless-stopped
ports:
- "3000:3000"
environment:
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET}
- APP_URL=${APP_URL:-http://localhost:3000}
- NEXTAUTH_URL=${APP_URL:-http://localhost:3000}
- SUPERADMIN_EMAIL=${SUPERADMIN_EMAIL:-}
- DB_PASSWORD=lynxprompt
- DB_APP_HOST=postgres
- DB_APP_USER=lynxprompt
- DB_APP_PASSWORD=lynxprompt
- DB_APP_NAME=lynxprompt
- DB_USERS_HOST=postgres
- DB_USERS_USER=lynxprompt
- DB_USERS_PASSWORD=lynxprompt
- DB_USERS_NAME=lynxprompt
- DB_BLOG_HOST=postgres
- DB_BLOG_USER=lynxprompt
- DB_BLOG_PASSWORD=lynxprompt
- DB_BLOG_NAME=lynxprompt
- DB_SUPPORT_HOST=postgres
- DB_SUPPORT_USER=lynxprompt
- DB_SUPPORT_PASSWORD=lynxprompt
- DB_SUPPORT_NAME=lynxprompt
- ENABLE_USER_REGISTRATION=true
- ENABLE_EMAIL_AUTH=true
- ENABLE_PASSKEYS=true
depends_on:
- postgres
postgres:
image: postgres:17-alpine
restart: unless-stopped
environment:
- POSTGRES_DB=lynxprompt
- POSTGRES_USER=lynxprompt
- POSTGRES_PASSWORD=lynxprompt
volumes:
- postgres-data:/var/lib/postgresql/data
- uploads:/data/uploads
depends_on:
postgres:
condition: service_healthy
environment:
# One database per Prisma schema; the app creates the other three on first start.
DB_APP_HOST: postgres
DB_APP_USER: lynxprompt
DB_APP_PASSWORD: ${DB_PASSWORD}
DB_APP_NAME: lynxprompt
DB_USERS_HOST: postgres
DB_USERS_USER: lynxprompt
DB_USERS_PASSWORD: ${DB_PASSWORD}
DB_USERS_NAME: lynxprompt_users
DB_BLOG_HOST: postgres
DB_BLOG_USER: lynxprompt
DB_BLOG_PASSWORD: ${DB_PASSWORD}
DB_BLOG_NAME: lynxprompt_blog
DB_SUPPORT_HOST: postgres
DB_SUPPORT_USER: lynxprompt
DB_SUPPORT_PASSWORD: ${DB_PASSWORD}
DB_SUPPORT_NAME: lynxprompt_support
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET:?NEXTAUTH_SECRET must be set}
NEXTAUTH_URL: ${APP_URL:?APP_URL must be set}
APP_URL: ${APP_URL:?APP_URL must be set}
SUPERADMIN_EMAIL: ${SUPERADMIN_EMAIL:-}
# Sign-in is a link sent by mail, so SMTP is needed to log in.
SMTP_HOST: ${SMTP_HOST:?SMTP_HOST must be set}
SMTP_PORT: ${SMTP_PORT:-587}
SMTP_USER: ${SMTP_USER:-}
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
SMTP_FROM: ${SMTP_FROM:?SMTP_FROM must be set}
volumes:
postgres-data:
pgdata:
uploads:
+9071 -1781
View File
File diff suppressed because it is too large. Load diff