···11+---
22+title: Renovate + Komodo - Updating at Scale in a Large Homelab
33+description: >-
44+ Renovate rules and Komodo actions for managing updates in a 50+ stack homelab
55+author: FoxxMD
66+categories: [Tutorial]
77+tags: [docker, renovate, komodo]
88+pin: false
99+mermaid: false
1010+date: 2026-04-20 08:41:00 -0400
1111+---
1212+1313+## Intro
1414+1515+If you are already in the [Komodo ecosystem](./migrating-to-komodo) you have probably run across across [Nick Cunningham's guide](https://nickcunningh.am/blog/how-to-automate-version-updates-for-your-self-hosted-docker-containers-with-gitea-renovate-and-komodo) on setting up [Renovate Bot](https://docs.renovatebot.com/) to manage docker image updates in your compose stacks.
1616+1717+Nick's guide is *excellent* for getting all the infrastructure set up for this scenario but stops a little short of providing an *exhaustive*, opinionated way of actually configuring Renovate to be used in your lab. Yes, it does [provide a renovate config](https://nickcunningh.am/blog/how-to-automate-version-updates-for-your-self-hosted-docker-containers-with-gitea-renovate-and-komodo#configure-renovate-in-the-repo) with a few rules that do work well for a trivial use case but this config becomes unwieldy quickly as the number of stacks in your repo grows.
1818+1919+This guide builds on top of Nick's fantastic starting point: **I present further configuration for Renovate and Komodo that will help you keep the noise level low in a lab with 100+ compose stacks.**
2020+2121+### Prerequisties
2222+2323+If you aren't already familiar with the above topics or don't have everything set up you will need these pieces of infrastructure in place first (in this order):
2424+2525+* [Komodo](./migrating-to-komodo) configured with [Stacks](./migrating-to-komodo#creating-stacks) utilizing [Git Repo(s)](./migrating-to-komodo#creating-stacks)
2626+ * Renovate will work best with the monorepo strategy described in the links above, but it's also useable with per-repo stacks if you want to do that.
2727+* Not *strictly* necessary but using Komodo, forgejo + webhooks, and the optional registry proxy-cache will be much easier if you have some kind of [reverse proxy](./migrating-to-traefik/) set up with all of these services tied into it. That will also require [DNS for the reverse proxy](./redundant-lan-dns), however you want to implement it.
2828+ * This guide will assume you have this configured. If you do not then anywhere you see an example address like `https://subdomain.example.com` you'll need to substitute it for the respective `http://serviceHostIp:port` in your set up.
2929+* (Using Nick's guide) [Forgejo](https://nickcunningh.am/blog/how-to-setup-and-configure-forgejo-with-support-for-forgejo-actions-and-more), [Forgejo Actions](https://nickcunningh.am/blog/how-to-setup-and-configure-forgejo-with-support-for-forgejo-actions-and-more#setting-up-forgejo-actions), and [Renovate Bot as a repo connected to Actions](https://nickcunningh.am/blog/how-to-automate-version-updates-for-your-self-hosted-docker-containers-with-gitea-renovate-and-komodo#setting-up-renovate)
3030+3131+## Limiting Deployments on Komodo to Non-Critical Stacks
3232+3333+Nick's guide uses Forgejo webhooks on your repository to trigger a [Komodo Procedure](https://nickcunningh.am/blog/how-to-automate-version-updates-for-your-self-hosted-docker-containers-with-gitea-renovate-and-komodo#create-a-procedure-in-komodo) to run. The Procedure is a `Batch Deploy Stack If Changed` action that targets *all* Stacks. There are two issues with this.
3434+3535+### The Problem
3636+3737+#### Forgejo Webhooks Triggers are Not Granular
3838+3939+Forgejo webhooks can be enabled to trigger on *any* push to the repository or *any* PR state change (opened, closed, synchronized, etc...). From the UI you cannot control any other conditions on these types of triggers. What this means in practice is that:
4040+4141+* You *cannot* only trigger on repo push events **only from the renovate bot**
4242+ * EX You make changes in Komodo to a stack and write the contents -> This commits to the repo (as your user) -> triggers webhook
4343+ * This is undesired! We only want webhooks triggered if we are merging commits from renovate bot, not just for any random change we make in Komodo
4444+* PR state changes cannot be specified
4545+ * EX You close a PR without merged -> triggered webhook
4646+ * We really only want to trigger if a PR is successfully merged, not for every single rebase, comment, etc...
4747+4848+So even though our intent is to only have Forgejo trigger Komodo deployments when we merge a PR from Renovate Bot our Forgejo webhook triggers are too broad and there's nothing we can really do about it from the repo ui.
4949+5050+#### Komodo Procedure can re-deploy Critical Infrastructure
5151+5252+While the `Batch Deploy Stack If Changed` action *is smart enough* to only re-deploy stacks that have new image updates or have changes to their compose.yaml contents there are still many scenarios where we might not want this happen. Using the procedure config from Nick's guide, using `*` as a target, *every stack that has pending changes will be re-deployed*. That potentially includes:
5353+5454+* the Forgejo instance
5555+* your reverse proxy
5656+* DNS
5757+* periphery containers
5858+* etc...
5959+6060+These are stacks that we depend on to sucessfully deploy other stacks. You don't want Forgejo restarting while Komodo is trying to pull from it for a deployment somewhere else!
6161+6262+While we can change the target of `Batch Deploy Stack If Changed`, it is *inclusive*. It's not feasible to specify every single stack that could be deployed as we'd have to update it every time we add a new Stack to Komodo. And once the number of stacks is non-trivial this becomes impractical.
6363+6464+### The Solution
6565+6666+We can address both of the above issues by writing our own [Komodo **Actions**](https://komo.do/docs/automate/procedures#actions) to
6767+6868+1. **filter webhook triggers to only renovate bot and**
6969+2. **trigger a `Batch Deploy Stack If Changed` with targets based on Tags.**
7070+7171+#### 1. Tag Critical Infra
7272+7373+**Tags** are presented as a visual-organization tool in Komodo but they can be used for much more than that: Tags are surfaced as a property of all Komodo Resources when querying the API. We can therefore use these as a signal to *exclude* resources from being deployed.
7474+7575+First, you'll need to tag the relevant resources, in the Komodo UI:
7676+7777+* Create a new Tag
7878+ * **Settings** -> **Tags** -> click **+ New Tag** button
7979+ * Name the tag `critical-infa` -> **Create**
8080+* Tag your Stacks
8181+ * Open the **Stacks** page
8282+ * Find each Stack that you consider critical infrastructure and open the Details page for it
8383+ * Below the Stack title click on the plus (+) button next to **Tags** and add the tag you created above
8484+8585+Nice! Now all your Stacks are tagged, we will use this in the Action created below.
8686+8787+#### 2. Create `Batch Deploy If Changed Except` Action
8888+8989+Create a new Komodo **Action** named `batch-deploy-changed-and-exclude` and copy-paste the contents of the (expanded) block below:
9090+9191+<details markdown="1">
9292+9393+<summary>Batch Deploy If Changed Except Contents</summary>
9494+9595+```ts
9696+// add values to each filter to NOT re-deploy if stack contains X
9797+const REPOS = ARGS.REPOS === undefined ? [] : ARGS.REPOS.split(','); // Stack X Repo 'MyName/MyRepo' includes ANY part of string Y from list
9898+const SERVER_IDS = ARGS.SERVER_IDS === undefined ? [] : ARGS.SERVER_ID.split(','); // Stack X Server '67659da61af880a9d21f25be' matches string Y from list
9999+const TAGS = ARGS.TAGS === undefined ? [] : ARGS.TAGS.split(','); // Stack X Tags A,B,C like 'myCoolTag' matches string Y from list
100100+const STACKS = ARGS.STACKS === undefined ? [] : ARGS.STACKS.split(','); // Stack 'my-cool-stack' matches ANY part of string Y from list
101101+const SERVICES = ARGS.SERVICES === undefined ? [] : ARGS.SERVICES.split(','); // Stack X Service 'my-cool-service' includes ANY part of string Y from list
102102+const IMAGES = ARGS.IMAGES === undefined ? [] : ARGS.IMAGES.split(','); // Stack X Image 'lscr.io/linuxserver/socket-proxy:latest' includes ANY part of string Y from list
103103+104104+// if ARGS.COMMIT is not present and `true` then this action will only "dry run" the changes
105105+// it will log to console what it *would* do but will not actually execute any changes
106106+const commit = ARGS.COMMIT === 'true';
107107+108108+// used for getting common values found in two different lists
109109+const intersect = (a: Array<any>, b: Array<any>) => {
110110+ const setA = new Set(a);
111111+ const setB = new Set(b);
112112+ const intersection = new Set([...setA].filter(x => setB.has(x)));
113113+ return Array.from(intersection);
114114+}
115115+116116+// formats stack names nicely in console out
117117+const formatColumns = (arr: string[], numCols: number) => {
118118+ if (!arr || arr.length === 0) return "";
119119+120120+ // Calculate the width of each column (based on longest string in that column)
121121+ const colWidths = Array.from({ length: numCols }, (_, colIndex) => {
122122+ let maxWidth = 0;
123123+ for (let i = colIndex; i < arr.length; i += numCols) {
124124+ if (arr[i].length > maxWidth) maxWidth = arr[i].length;
125125+ }
126126+ return maxWidth;
127127+ });
128128+129129+ // Build the output row by row
130130+ const rows = [];
131131+ for (let i = 0; i < arr.length; i += numCols) {
132132+ const rowItems = arr.slice(i, i + numCols);
133133+ const row = rowItems
134134+ .map((item, colIndex) => item.padEnd(colWidths[colIndex]))
135135+ .join(" "); // 2-space separator between columns
136136+ rows.push(row.trimEnd());
137137+ }
138138+139139+ return rows.join("\n");
140140+}
141141+142142+const availableUpdates = await komodo.read('ListStacks', {});
143143+144144+let userTags: string[] = [];
145145+let tagsList: Types.ListTagsResponse;
146146+if(TAGS.length > 0) {
147147+ tagsList = await komodo.read('ListTags', {});
148148+ userTags = tagsList.filter(x => TAGS.includes(x.name)).map(x => x._id.$oid);
149149+}
150150+151151+const excluded: string[] = [];
152152+153153+const candidates = availableUpdates.filter(x => {
154154+ if(REPOS.length > 0 && REPOS.some(x => x.info.repo.includes(x))) {
155155+ excluded.push(`${x.name} => repo`);
156156+ return false;
157157+ }
158158+ if(SERVER_IDS.length > 0 && SERVER_IDS.includes(x.info.server_id)) {
159159+ excluded.push(`${x.name} => server`);
160160+ return false;
161161+ }
162162+ if(TAGS.length > 0 && intersect(userTags, x.tags).length > 0) {
163163+ const intersectedTags = intersect(userTags, x.tags);
164164+ excluded.push(`${x.name} => tags ${tagsList.filter(x => intersectedTags.includes(x._id.$oid)).map(x => x.name).join(',')}`);
165165+ return false;
166166+ }
167167+ if(STACKS.length > 0 && STACKS.some(y => x.name.includes(y))) {
168168+ excluded.push(`${x.name} => stack`);
169169+ return false;
170170+ }
171171+ if(SERVICES.length > 0) {
172172+ const s = x.info.services.map(x => x.service);
173173+ if(s.some(x => SERVICES.some(y => x.includes(y)))) {
174174+ excluded.push(`${x.name} => service`);
175175+ return false;
176176+ }
177177+ }
178178+ if(IMAGES.length > 0) {
179179+ const s = x.info.services.map(x => x.image);
180180+ if(s.some(x => IMAGES.includes(y => y.includes(s)))) {
181181+ excluded.push(`${x.name} => image`);
182182+ return false;
183183+ }
184184+ }
185185+ return true;
186186+});
187187+188188+if(excluded.length > 0) {
189189+ console.log(`Excluded ${excluded.length} Stacks:\n${formatColumns(excluded, 3)}`);
190190+}
191191+192192+console.log(`\n${commit === false ? '[DRY RUN] ' : ''}Will deploy ${candidates.length} if changed:
193193+${formatColumns(candidates.map(x => x.name), 3)}`);
194194+195195+if(commit) {
196196+ await komodo.execute('BatchDeployStackIfChanged', {pattern: candidates.map(x => x.id).join(',')});
197197+}
198198+```
199199+{: file='Action File'}
200200+201201+</details>
202202+203203+In short, this Action:
204204+205205+* Gets *all* Stacks on *all* Servers in your Komodo instance
206206+* Iterates through each one
207207+ * If the Stack has a property found in one of the lists of variables from the top of the file -- `REPOS`, `SERVER_IDS`, `TAGS`, `STACKS`, `SERVICES,` and `IMAGES` -- then it is **excluded** from the list of Stacks that may be deployed
208208+* At the end, all Stacks that *did not match* one of those properties is used as the target of a `BatchDeployStackIfChanged` API call
209209+ * Only if `ARGS.COMMIT = true`
210210+211211+The top-of-file variables are parsed from **Arguments** which means they not hardcoded and can be passed by other actions/procedures/komodo api calls.
212212+213213+So, this Action gives us a way to `Deploy Stack If Changed` where we can exclude all of our Stacks tagged with `critical-infra`.
214214+215215+> This action is generic! We are using it in this scenario only for excluding by tags but you can use it for *anything* by using the other Arguments/Variables defined at the top.
216216+>
217217+> EX You could also make a duplicate of the Action and change the api call executed from `BatchDeployStackIfChanged` to `StartAllContainers` to try to start all containers after restarting a server, but exclude ones with heavy IO.
218218+{: .prompt-tip}
219219+220220+#### 3. Create Procedure to Batch Deploy and Exclude Critical Infra
221221+222222+If you already created a [Batch Deploy Procedure using Nick's guide](https://nickcunningh.am/blog/how-to-automate-version-updates-for-your-self-hosted-docker-containers-with-gitea-renovate-and-komodo#create-a-procedure-in-komodo) you should modify it to match this one, now.
223223+224224+Create a new Komodo **Procedure** named **Update On PR Merge** and add a new Stage, specifying the Action we just created `batch-deploy-changed-and-exclude`, with args for our critical infrastructure tag: `{ "TAGS": "critical-infra" }`
225225+226226+
227227+228228+**Save** the Procedure.
229229+230230+> This could technically be achieved from our Forgejo Webhook Action by directly executing the `batch-deploy-changed-and-exclude` action with `komodo.execute` but I prefer to use a Procedure as it lets you easily expand/modify the behavior later to include other Stages/Actions.
231231+{: .prompt-info}
232232+233233+#### 4. Create Forgejo-Webhook-Filtering Komodo Action
234234+235235+Create a new Komodo **Action** named `Renovate Git Commit` and copy-paste the contents of the (expanded) block below:
236236+237237+<details markdown="1">
238238+239239+<summary>Batch Deploy If Changed Except Contents</summary>
240240+241241+```ts
242242+// ARGS
243243+// RENOVATE_USER => username of the renovate bot
244244+// BASE_BRANCH => branch being merged into
245245+// PROCEDURE_ID => id of procedure to trigger
246246+// COMMIT => bool, whether to trigger procedure
247247+248248+const body = ARGS.WEBHOOK_BODY;
249249+const commit = ARGS.COMMIT === 'true';
250250+251251+const {
252252+ commits = []
253253+} = body;
254254+255255+if(commits.length > 0) {
256256+ const {
257257+ ref,
258258+ } = body;
259259+260260+ if(ARGS.RENOVATE_USER === undefined) {
261261+ throw new Error('RENOVATE_USER arg must be defined!');
262262+ }
263263+264264+ if(ARGS.BASE_BRANCH !== undefined) {
265265+ if(!ref.includes(ARGS.BASE_BRANCH)) {
266266+ console.log(`Base Branch wanted '${ARGS.BASE_BRANCH} but found ${ref}, ignoring this webhook event.'`);
267267+ return;
268268+ }
269269+ } else {
270270+ console.log('No Base Branch check required.');
271271+ }
272272+273273+ const renovateCommits = commits.filter(x => {
274274+ const {
275275+ author: {
276276+ username: authorUser
277277+ } = {},
278278+ committer: {
279279+ username: commitUser
280280+ } = {}
281281+ } = x;
282282+ return authorUser === ARGS.RENOVATE_USER || commitUser === ARGS.RENOVATE_USER;
283283+ });
284284+285285+ if(renovateCommits.length === 0) {
286286+ console.log(`No commits by username ${ARGS.RENOVATE_USER}`);
287287+ return;
288288+ }
289289+290290+ console.log(`Found ${renovateCommits.length} by username ${ARGS.RENOVATE_USER}:\n${renovateCommits.map(x => x.message).join('\n')}`);
291291+292292+ const pid = ARGS.PROCEDURE_ID;
293293+ console.log(`${commit === false ? '[DRY RUN] ' : ''} Triggering procedure ${pid}`);
294294+ if(undefined === pid) {
295295+ throw new Error('Cannot trigger procedure because no ID was provided as arg PROCEDURE_ID');
296296+ }
297297+ if(commit) {
298298+ komodo.execute('RunProcedure', {procedure: pid});
299299+ }
300300+}
301301+```
302302+{: file='Action File'}
303303+304304+</details>
305305+306306+307307+This Action:
308308+309309+* Recieves a Forgejo Webhook event and parses the body
310310+* If it's a commit event (checks for `commits`)...
311311+ * Checks that at least one commit was made by the Renovate Bot
312312+ * and optionally checks it was committed to a specific branch (like `main`)
313313+* If all conditions are met and `ARGS.COMMIT === 'true'` then it executes a procedure based on passed argument value
314314+315315+So, this Action filters all Forgejo Webhooks sent to it and only triggers a procedure if it contains a commit made by our Renovate Bot user.
316316+317317+The top-of-file variables are parsed from **Arguments** which means they not hardcoded and can be passed by other actions/procedures/komodo api calls. However, you *should* set **Default Arguments** in this Action as it will be triggered directly by Forgejo (which cannot specifically pass Komodo Arguments). Under the Action File section, add/modify this contents to Arguments:
318318+319319+```
320320+PROCEDURE_ID=69d54b1d82f3ae56abf97d88 # the procedure ID from step 3, get it from the procedure's page URL
321321+RENOVATE_USER=renovate-bot # the username of the renovate bot on your forgejo instance
322322+#BASE_BRANCH=main # uncomment this and specify branch to trigger only if commits are in this branch
323323+COMMIT=true
324324+```
325325+{: file='Arguments'}
326326+327327+Make sure **Key Value** type is set from the Argument dropdown.
328328+329329+Finally, we need to add this Action's webhook to Forgejo. This is the same step as in [Nick's guide](https://nickcunningh.am/blog/how-to-automate-version-updates-for-your-self-hosted-docker-containers-with-gitea-renovate-and-komodo#create-a-procedure-in-komodo):
330330+331331+* At the bottom of the Action File...
332332+ * Enable **Webhook Enabled** and **Save**
333333+ * Copy the **Webhook URL - Run** value
334334+ * Make sure you know your Webhook Secret value as well
335335+* Open your Forgejo Repo -> Settings -> Webhooks
336336+ * Add new Webhook as *Gitea* style, use the URL value from above and add Komodo secret
337337+ * Enable for Custom Events...
338338+ * Check box for **Push**
339339+340340+#### Summary
341341+342342+Hooray! You've done it. To summarize the chain of events, now:
343343+344344+* Renovate Bot makes a PR to your repo
345345+* You commit/merge the PR
346346+* Forgejo triggers the webhook because of the Push event
347347+ * This sends the commit event payload in the request to Komodo
348348+* Komodo Action `Renovate Git Commit` recieves the webhook body
349349+ * Checks that the at least one commit came from Renovate Bot and is to the right branch
350350+ * Executes Procedure from Step 3
351351+* Komodo Procedure executes `batch-deploy-changed-and-exclude` Action with arguments for `critical-infra` tag
352352+ * Komodo runs **Batch Deploy Stack If Changed** on all Stacks except those with the `critical-infra` tag
353353+354354+So, we now:
355355+356356+* avoid spamming Komodo Action webhook with any/all push/pr actions from our repo
357357+* only re-deploy changed Stacks that don't include the Stacks we potentially need to make the deployments happen in the first place