[READ-ONLY] Mirror of https://github.com/FoxxMD/blog. blog.foxxmd.dev
0

Configure Feed

Select the types of activity you want to include in your feed.

feat(renovate): Limiting stack deploys

FoxxMD (Apr 20, 2026, 3:07 PM UTC) dcdae4c7 b78dd527

+357
+357
_posts/2026-04-20-scaling-renovate.md
··· 1 + --- 2 + title: Renovate + Komodo - Updating at Scale in a Large Homelab 3 + description: >- 4 + Renovate rules and Komodo actions for managing updates in a 50+ stack homelab 5 + author: FoxxMD 6 + categories: [Tutorial] 7 + tags: [docker, renovate, komodo] 8 + pin: false 9 + mermaid: false 10 + date: 2026-04-20 08:41:00 -0400 11 + --- 12 + 13 + ## Intro 14 + 15 + 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. 16 + 17 + 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. 18 + 19 + 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.** 20 + 21 + ### Prerequisties 22 + 23 + 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): 24 + 25 + * [Komodo](./migrating-to-komodo) configured with [Stacks](./migrating-to-komodo#creating-stacks) utilizing [Git Repo(s)](./migrating-to-komodo#creating-stacks) 26 + * 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. 27 + * 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. 28 + * 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. 29 + * (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) 30 + 31 + ## Limiting Deployments on Komodo to Non-Critical Stacks 32 + 33 + 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. 34 + 35 + ### The Problem 36 + 37 + #### Forgejo Webhooks Triggers are Not Granular 38 + 39 + 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: 40 + 41 + * You *cannot* only trigger on repo push events **only from the renovate bot** 42 + * EX You make changes in Komodo to a stack and write the contents -> This commits to the repo (as your user) -> triggers webhook 43 + * 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 44 + * PR state changes cannot be specified 45 + * EX You close a PR without merged -> triggered webhook 46 + * We really only want to trigger if a PR is successfully merged, not for every single rebase, comment, etc... 47 + 48 + 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. 49 + 50 + #### Komodo Procedure can re-deploy Critical Infrastructure 51 + 52 + 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: 53 + 54 + * the Forgejo instance 55 + * your reverse proxy 56 + * DNS 57 + * periphery containers 58 + * etc... 59 + 60 + 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! 61 + 62 + 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. 63 + 64 + ### The Solution 65 + 66 + We can address both of the above issues by writing our own [Komodo **Actions**](https://komo.do/docs/automate/procedures#actions) to 67 + 68 + 1. **filter webhook triggers to only renovate bot and** 69 + 2. **trigger a `Batch Deploy Stack If Changed` with targets based on Tags.** 70 + 71 + #### 1. Tag Critical Infra 72 + 73 + **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. 74 + 75 + First, you'll need to tag the relevant resources, in the Komodo UI: 76 + 77 + * Create a new Tag 78 + * **Settings** -> **Tags** -> click **+ New Tag** button 79 + * Name the tag `critical-infa` -> **Create** 80 + * Tag your Stacks 81 + * Open the **Stacks** page 82 + * Find each Stack that you consider critical infrastructure and open the Details page for it 83 + * Below the Stack title click on the plus (+) button next to **Tags** and add the tag you created above 84 + 85 + Nice! Now all your Stacks are tagged, we will use this in the Action created below. 86 + 87 + #### 2. Create `Batch Deploy If Changed Except` Action 88 + 89 + Create a new Komodo **Action** named `batch-deploy-changed-and-exclude` and copy-paste the contents of the (expanded) block below: 90 + 91 + <details markdown="1"> 92 + 93 + <summary>Batch Deploy If Changed Except Contents</summary> 94 + 95 + ```ts 96 + // add values to each filter to NOT re-deploy if stack contains X 97 + const REPOS = ARGS.REPOS === undefined ? [] : ARGS.REPOS.split(','); // Stack X Repo 'MyName/MyRepo' includes ANY part of string Y from list 98 + const SERVER_IDS = ARGS.SERVER_IDS === undefined ? [] : ARGS.SERVER_ID.split(','); // Stack X Server '67659da61af880a9d21f25be' matches string Y from list 99 + const TAGS = ARGS.TAGS === undefined ? [] : ARGS.TAGS.split(','); // Stack X Tags A,B,C like 'myCoolTag' matches string Y from list 100 + const STACKS = ARGS.STACKS === undefined ? [] : ARGS.STACKS.split(','); // Stack 'my-cool-stack' matches ANY part of string Y from list 101 + const SERVICES = ARGS.SERVICES === undefined ? [] : ARGS.SERVICES.split(','); // Stack X Service 'my-cool-service' includes ANY part of string Y from list 102 + 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 103 + 104 + // if ARGS.COMMIT is not present and `true` then this action will only "dry run" the changes 105 + // it will log to console what it *would* do but will not actually execute any changes 106 + const commit = ARGS.COMMIT === 'true'; 107 + 108 + // used for getting common values found in two different lists 109 + const intersect = (a: Array<any>, b: Array<any>) => { 110 + const setA = new Set(a); 111 + const setB = new Set(b); 112 + const intersection = new Set([...setA].filter(x => setB.has(x))); 113 + return Array.from(intersection); 114 + } 115 + 116 + // formats stack names nicely in console out 117 + const formatColumns = (arr: string[], numCols: number) => { 118 + if (!arr || arr.length === 0) return ""; 119 + 120 + // Calculate the width of each column (based on longest string in that column) 121 + const colWidths = Array.from({ length: numCols }, (_, colIndex) => { 122 + let maxWidth = 0; 123 + for (let i = colIndex; i < arr.length; i += numCols) { 124 + if (arr[i].length > maxWidth) maxWidth = arr[i].length; 125 + } 126 + return maxWidth; 127 + }); 128 + 129 + // Build the output row by row 130 + const rows = []; 131 + for (let i = 0; i < arr.length; i += numCols) { 132 + const rowItems = arr.slice(i, i + numCols); 133 + const row = rowItems 134 + .map((item, colIndex) => item.padEnd(colWidths[colIndex])) 135 + .join(" "); // 2-space separator between columns 136 + rows.push(row.trimEnd()); 137 + } 138 + 139 + return rows.join("\n"); 140 + } 141 + 142 + const availableUpdates = await komodo.read('ListStacks', {}); 143 + 144 + let userTags: string[] = []; 145 + let tagsList: Types.ListTagsResponse; 146 + if(TAGS.length > 0) { 147 + tagsList = await komodo.read('ListTags', {}); 148 + userTags = tagsList.filter(x => TAGS.includes(x.name)).map(x => x._id.$oid); 149 + } 150 + 151 + const excluded: string[] = []; 152 + 153 + const candidates = availableUpdates.filter(x => { 154 + if(REPOS.length > 0 && REPOS.some(x => x.info.repo.includes(x))) { 155 + excluded.push(`${x.name} => repo`); 156 + return false; 157 + } 158 + if(SERVER_IDS.length > 0 && SERVER_IDS.includes(x.info.server_id)) { 159 + excluded.push(`${x.name} => server`); 160 + return false; 161 + } 162 + if(TAGS.length > 0 && intersect(userTags, x.tags).length > 0) { 163 + const intersectedTags = intersect(userTags, x.tags); 164 + excluded.push(`${x.name} => tags ${tagsList.filter(x => intersectedTags.includes(x._id.$oid)).map(x => x.name).join(',')}`); 165 + return false; 166 + } 167 + if(STACKS.length > 0 && STACKS.some(y => x.name.includes(y))) { 168 + excluded.push(`${x.name} => stack`); 169 + return false; 170 + } 171 + if(SERVICES.length > 0) { 172 + const s = x.info.services.map(x => x.service); 173 + if(s.some(x => SERVICES.some(y => x.includes(y)))) { 174 + excluded.push(`${x.name} => service`); 175 + return false; 176 + } 177 + } 178 + if(IMAGES.length > 0) { 179 + const s = x.info.services.map(x => x.image); 180 + if(s.some(x => IMAGES.includes(y => y.includes(s)))) { 181 + excluded.push(`${x.name} => image`); 182 + return false; 183 + } 184 + } 185 + return true; 186 + }); 187 + 188 + if(excluded.length > 0) { 189 + console.log(`Excluded ${excluded.length} Stacks:\n${formatColumns(excluded, 3)}`); 190 + } 191 + 192 + console.log(`\n${commit === false ? '[DRY RUN] ' : ''}Will deploy ${candidates.length} if changed: 193 + ${formatColumns(candidates.map(x => x.name), 3)}`); 194 + 195 + if(commit) { 196 + await komodo.execute('BatchDeployStackIfChanged', {pattern: candidates.map(x => x.id).join(',')}); 197 + } 198 + ``` 199 + {: file='Action File'} 200 + 201 + </details> 202 + 203 + In short, this Action: 204 + 205 + * Gets *all* Stacks on *all* Servers in your Komodo instance 206 + * Iterates through each one 207 + * 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 208 + * At the end, all Stacks that *did not match* one of those properties is used as the target of a `BatchDeployStackIfChanged` API call 209 + * Only if `ARGS.COMMIT = true` 210 + 211 + 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. 212 + 213 + So, this Action gives us a way to `Deploy Stack If Changed` where we can exclude all of our Stacks tagged with `critical-infra`. 214 + 215 + > 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. 216 + > 217 + > 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. 218 + {: .prompt-tip} 219 + 220 + #### 3. Create Procedure to Batch Deploy and Exclude Critical Infra 221 + 222 + 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. 223 + 224 + 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" }` 225 + 226 + ![pr merge procedure](assets/img/renovate/procedure.png) 227 + 228 + **Save** the Procedure. 229 + 230 + > 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. 231 + {: .prompt-info} 232 + 233 + #### 4. Create Forgejo-Webhook-Filtering Komodo Action 234 + 235 + Create a new Komodo **Action** named `Renovate Git Commit` and copy-paste the contents of the (expanded) block below: 236 + 237 + <details markdown="1"> 238 + 239 + <summary>Batch Deploy If Changed Except Contents</summary> 240 + 241 + ```ts 242 + // ARGS 243 + // RENOVATE_USER => username of the renovate bot 244 + // BASE_BRANCH => branch being merged into 245 + // PROCEDURE_ID => id of procedure to trigger 246 + // COMMIT => bool, whether to trigger procedure 247 + 248 + const body = ARGS.WEBHOOK_BODY; 249 + const commit = ARGS.COMMIT === 'true'; 250 + 251 + const { 252 + commits = [] 253 + } = body; 254 + 255 + if(commits.length > 0) { 256 + const { 257 + ref, 258 + } = body; 259 + 260 + if(ARGS.RENOVATE_USER === undefined) { 261 + throw new Error('RENOVATE_USER arg must be defined!'); 262 + } 263 + 264 + if(ARGS.BASE_BRANCH !== undefined) { 265 + if(!ref.includes(ARGS.BASE_BRANCH)) { 266 + console.log(`Base Branch wanted '${ARGS.BASE_BRANCH} but found ${ref}, ignoring this webhook event.'`); 267 + return; 268 + } 269 + } else { 270 + console.log('No Base Branch check required.'); 271 + } 272 + 273 + const renovateCommits = commits.filter(x => { 274 + const { 275 + author: { 276 + username: authorUser 277 + } = {}, 278 + committer: { 279 + username: commitUser 280 + } = {} 281 + } = x; 282 + return authorUser === ARGS.RENOVATE_USER || commitUser === ARGS.RENOVATE_USER; 283 + }); 284 + 285 + if(renovateCommits.length === 0) { 286 + console.log(`No commits by username ${ARGS.RENOVATE_USER}`); 287 + return; 288 + } 289 + 290 + console.log(`Found ${renovateCommits.length} by username ${ARGS.RENOVATE_USER}:\n${renovateCommits.map(x => x.message).join('\n')}`); 291 + 292 + const pid = ARGS.PROCEDURE_ID; 293 + console.log(`${commit === false ? '[DRY RUN] ' : ''} Triggering procedure ${pid}`); 294 + if(undefined === pid) { 295 + throw new Error('Cannot trigger procedure because no ID was provided as arg PROCEDURE_ID'); 296 + } 297 + if(commit) { 298 + komodo.execute('RunProcedure', {procedure: pid}); 299 + } 300 + } 301 + ``` 302 + {: file='Action File'} 303 + 304 + </details> 305 + 306 + 307 + This Action: 308 + 309 + * Recieves a Forgejo Webhook event and parses the body 310 + * If it's a commit event (checks for `commits`)... 311 + * Checks that at least one commit was made by the Renovate Bot 312 + * and optionally checks it was committed to a specific branch (like `main`) 313 + * If all conditions are met and `ARGS.COMMIT === 'true'` then it executes a procedure based on passed argument value 314 + 315 + 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. 316 + 317 + 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: 318 + 319 + ``` 320 + PROCEDURE_ID=69d54b1d82f3ae56abf97d88 # the procedure ID from step 3, get it from the procedure's page URL 321 + RENOVATE_USER=renovate-bot # the username of the renovate bot on your forgejo instance 322 + #BASE_BRANCH=main # uncomment this and specify branch to trigger only if commits are in this branch 323 + COMMIT=true 324 + ``` 325 + {: file='Arguments'} 326 + 327 + Make sure **Key Value** type is set from the Argument dropdown. 328 + 329 + 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): 330 + 331 + * At the bottom of the Action File... 332 + * Enable **Webhook Enabled** and **Save** 333 + * Copy the **Webhook URL - Run** value 334 + * Make sure you know your Webhook Secret value as well 335 + * Open your Forgejo Repo -> Settings -> Webhooks 336 + * Add new Webhook as *Gitea* style, use the URL value from above and add Komodo secret 337 + * Enable for Custom Events... 338 + * Check box for **Push** 339 + 340 + #### Summary 341 + 342 + Hooray! You've done it. To summarize the chain of events, now: 343 + 344 + * Renovate Bot makes a PR to your repo 345 + * You commit/merge the PR 346 + * Forgejo triggers the webhook because of the Push event 347 + * This sends the commit event payload in the request to Komodo 348 + * Komodo Action `Renovate Git Commit` recieves the webhook body 349 + * Checks that the at least one commit came from Renovate Bot and is to the right branch 350 + * Executes Procedure from Step 3 351 + * Komodo Procedure executes `batch-deploy-changed-and-exclude` Action with arguments for `critical-infra` tag 352 + * Komodo runs **Batch Deploy Stack If Changed** on all Stacks except those with the `critical-infra` tag 353 + 354 + So, we now: 355 + 356 + * avoid spamming Komodo Action webhook with any/all push/pr actions from our repo 357 + * only re-deploy changed Stacks that don't include the Stacks we potentially need to make the deployments happen in the first place
assets/img/renovate/procedure.png

This is a binary file and will not be displayed.