{"id":1099,"date":"2026-09-21T09:30:00","date_gmt":"2026-09-20T23:30:00","guid":{"rendered":"https:\/\/catharsis.net.au\/blog\/gitops-for-a-gpu-ai-server\/"},"modified":"2026-11-03T20:28:01","modified_gmt":"2026-11-03T09:28:01","slug":"gitops-for-a-gpu-ai-server","status":"publish","type":"post","link":"https:\/\/catharsis.net.au\/blog\/gitops-for-a-gpu-ai-server\/","title":{"rendered":"How Do You Keep an AI Server Stable? GitOps for Local AI (Part 6 of 11)"},"content":{"rendered":"\n<p class=\"has-pale-cyan-blue-background-color has-background wp-block-paragraph\">A silent image upgrade, a pruned container image and a backup that had quietly stopped pushed us to run our lab AI server from a git repo. Here is what we built, the GPU trap we fell into, how to set up AI coding agents safely, and the steps to build the same thing yourself.<\/p>\n\n\n\n<div class=\"wp-block-group has-background\" style=\"background-color:#f4f6f8;padding-top:28px;padding-right:32px;padding-bottom:28px;padding-left:32px\"><div class=\"wp-block-group__inner-container is-layout-constrained wp-container-core-group-is-layout-40de7cf3 wp-block-group-is-layout-constrained\">\n\n<p class=\"wp-block-paragraph\"><strong>Where this fits \u00b7 Running Local AI in Production \u00b7 Part 6 of 11<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\" style=\"font-size:17px\">You are reading Part 6 of 11 in a series where we built a private AI server in our lab and ran realistic test workloads on it for seven weeks. Each part stands on its own. The previous part covered routing requests across models on a single GPU without constant model swaps; this one covers running the server from a Git repository: deploys, drift, pinning and safe AI agents.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\" style=\"font-size:16px\"><a href=\"https:\/\/catharsis.net.au\/blog\/smart-routing-single-gpu-local-llm\/\" target=\"_blank\" rel=\"noreferrer noopener\">\u2190 Part 5: How Do You Run Several AI Models on One GPU Without Slowdowns?<\/a> &nbsp;\u00b7&nbsp; <a href=\"https:\/\/catharsis.net.au\/blog\/running-local-ai-in-production-series-guide\/\" target=\"_blank\" rel=\"noreferrer noopener\">Series guide<\/a> &nbsp;\u00b7&nbsp; <a href=\"https:\/\/catharsis.net.au\/blog\/observability-for-local-ai\/\" target=\"_blank\" rel=\"noreferrer noopener\">Part 7: What Is Your Local AI Server Actually Doing? Observability Basics \u2192<\/a><\/p>\n\n\n\n<p class=\"wp-block-paragraph\" style=\"font-size:16px\">Just want it working? Follow the <a href=\"https:\/\/catharsis.net.au\/blog\/local-ai-litellm-setup-guide\/\" target=\"_blank\" rel=\"noreferrer noopener\">step-by-step setup guide<\/a>: local AI with Ollama and LiteLLM, from a bare server to a secure gateway.<\/p>\n\n\n\n<details class=\"wp-block-details is-layout-flow wp-block-details-is-layout-flow\"><summary>See all 11 parts of the series<\/summary>\n\n<ol class=\"wp-block-list\">\n\n<li><a href=\"https:\/\/catharsis.net.au\/blog\/why-run-ai-on-your-own-hardware\/\" target=\"_blank\" rel=\"noreferrer noopener\">Local AI or Cloud AI? Why We Built a Private AI Server<\/a>: why we built the lab on our own hardware, what ran on it and what that choice cost<\/li>\n\n\n<li><a href=\"https:\/\/catharsis.net.au\/blog\/llm-gateway-one-door-for-every-model\/\" target=\"_blank\" rel=\"noreferrer noopener\">How Do You Control Access to Local AI Models? Use a Gateway<\/a>: one LLM gateway as the only way to reach a model: keys, budgets and tracing<\/li>\n\n\n<li><a href=\"https:\/\/catharsis.net.au\/blog\/local-ai-server-freezes-unified-memory\/\" target=\"_blank\" rel=\"noreferrer noopener\">Why Does a Local AI Server Freeze? Sizing Memory for LLMs<\/a>: five hard freezes and the memory budget that ended them<\/li>\n\n\n<li><a href=\"https:\/\/catharsis.net.au\/blog\/local-llm-context-budget\/\" target=\"_blank\" rel=\"noreferrer noopener\">What Happens When a Prompt Is Too Big for a Local LLM?<\/a>: fitting every prompt to a local model&#8217;s context window<\/li>\n\n\n<li><a href=\"https:\/\/catharsis.net.au\/blog\/smart-routing-single-gpu-local-llm\/\" target=\"_blank\" rel=\"noreferrer noopener\">How Do You Run Several AI Models on One GPU Without Slowdowns?<\/a>: routing requests across models on a single GPU without constant model swaps<\/li>\n\n\n<li><strong>How Do You Keep an AI Server Stable? GitOps for Local AI<\/strong> (you are here): running the server from a Git repository: deploys, drift, pinning and safe AI agents<\/li>\n\n\n<li><a href=\"https:\/\/catharsis.net.au\/blog\/observability-for-local-ai\/\" target=\"_blank\" rel=\"noreferrer noopener\">What Is Your Local AI Server Actually Doing? Observability Basics<\/a>: making the server observable: traces, dashboards, usage and heat<\/li>\n\n\n<li><a href=\"https:\/\/catharsis.net.au\/blog\/multi-agent-stock-analysis-local-llm\/\" target=\"_blank\" rel=\"noreferrer noopener\">Can Local AI Agents Analyse Stocks? How We Tested and Scored Them<\/a>: a multi-agent stock-analysis experiment and how we scored the agents<\/li>\n\n\n<li><a href=\"https:\/\/catharsis.net.au\/blog\/telegram-ai-agent-local-llms\/\" target=\"_blank\" rel=\"noreferrer noopener\">Why Was Our Telegram AI Agent So Slow? The Hidden Prompt Cost<\/a>: a Telegram AI agent on local models, and why its fixed prompt became the budget<\/li>\n\n\n<li><a href=\"https:\/\/catharsis.net.au\/blog\/local-ai-business-workflows\/\" target=\"_blank\" rel=\"noreferrer noopener\">Can AI Automate Business Workflows Without Sending Data Out?<\/a>: document, email and knowledge-base workflows that keep data on the server<\/li>\n\n\n<li><a href=\"https:\/\/catharsis.net.au\/blog\/private-ai-lab-lessons-and-whats-next\/\" target=\"_blank\" rel=\"noreferrer noopener\">Is Private AI Worth It? Lessons From Seven Weeks in Our Lab<\/a>: the lessons from seven weeks, the honest limits and what comes next<\/li>\n\n<\/ol>\n\n<\/details>\n\n<\/div><\/div>\n\n\n\n<p class=\"wp-block-paragraph\" style=\"font-size:16px\"><em>This series documents a private AI server we built and stress-tested in our lab. Workloads and data are synthetic or generalised; the problems, measurements and fixes are real.<\/em><\/p>\n\n\n\n<div class=\"wp-block-group has-background\" style=\"background-color:#f4f6f8;padding-top:28px;padding-right:32px;padding-bottom:28px;padding-left:32px\"><div class=\"wp-block-group__inner-container is-layout-constrained wp-container-core-group-is-layout-40de7cf3 wp-block-group-is-layout-constrained\">\n\n<h3 class=\"wp-block-heading\">TL;DR<\/h3>\n\n\n\n<ul class=\"wp-block-list\">\n\n<li>You keep an AI server stable by running it from a git repo: every configuration file lives in git, one deploy script is the only way to change the server and every image is pinned.<\/li>\n\n\n<li>The problem it solved: after a few weeks, nobody could say what was running on the lab AI server. A silent upgrade came just before three freezes, an image the OCR service needed was deleted, and the backup had quietly stopped.<\/li>\n\n\n<li>The result: every change now has a history and a rollback, a deliberate hand edit was caught and repaired, and a full host upgrade went through with no drift.<\/li>\n\n\n<li>The lessons: on a GPU server a systemd reload is a production event, and AI coding agents need scoped accounts and a repo-only path.<\/li>\n\n<\/ul>\n\n<\/div><\/div>\n\n\n\n<div class=\"wp-block-group has-background\" style=\"background-color:#f4f6f8;padding-top:28px;padding-right:32px;padding-bottom:28px;padding-left:32px\"><div class=\"wp-block-group__inner-container is-layout-constrained wp-container-core-group-is-layout-40de7cf3 wp-block-group-is-layout-constrained\">\n\n<details class=\"wp-block-details is-layout-flow wp-block-details-is-layout-flow\"><summary>Jargon buster: 15 terms used in this post, explained in plain English<\/summary>\n\n<ul class=\"wp-block-list\">\n\n<li><strong>Ollama<\/strong>: Free, open-source software that downloads and runs AI models on your own machine and answers requests over a local API.<\/li>\n\n\n<li><strong>LLM gateway<\/strong>: A single service every AI request must pass through, so access, budgets and logging are controlled in one place.<\/li>\n\n\n<li><strong>OCR<\/strong>: Optical character recognition: turning scanned documents or images of text into machine-readable text.<\/li>\n\n\n<li><strong>Docker \/ container<\/strong>: A way of packaging an application with everything it needs so it runs the same anywhere; each packaged application runs in its own container.<\/li>\n\n\n<li><strong>Docker Compose<\/strong>: A tool that defines and starts a set of related containers from one configuration file.<\/li>\n\n\n<li><strong>Socket proxy<\/strong>: A small service that sits between a tool and Docker and allows only safe, read-only requests, so monitoring tools can&#8217;t control the server.<\/li>\n\n\n<li><strong>Git repository<\/strong>: A folder of files tracked with Git, which records every change, who made it and when.<\/li>\n\n\n<li><strong>Configuration drift<\/strong>: When the live system slowly stops matching its recorded configuration because of manual or untracked changes.<\/li>\n\n\n<li><strong>Pinning<\/strong>: Locking software to an exact version so it can&#8217;t change unexpectedly during an update.<\/li>\n\n\n<li><strong>Pre-commit check<\/strong>: An automatic check that runs before a change is saved to the repository and blocks it if it breaks a rule.<\/li>\n\n\n<li><strong>systemd<\/strong>: The service manager on most Linux systems: it starts, stops and supervises background services.<\/li>\n\n\n<li><strong>cgroup<\/strong>: A Linux feature that limits how much CPU or memory a group of processes may use.<\/li>\n\n\n<li><strong>Cron job<\/strong>: A task scheduled to run automatically at set times, such as every night.<\/li>\n\n\n<li><strong>AI coding assistant<\/strong>: An AI tool that writes or changes code and can run commands for a developer.<\/li>\n\n\n<li><strong>CUDA \/ NVML<\/strong>: NVIDIA&#8217;s software for running programs on its GPUs (CUDA) and for monitoring them (NVML).<\/li>\n\n<\/ul>\n\n<\/details>\n\n<\/div><\/div>\n\n\n\n<div class=\"wp-block-group has-background\" style=\"background-color:#f4f6f8;padding-top:28px;padding-right:32px;padding-bottom:28px;padding-left:32px\"><div class=\"wp-block-group__inner-container is-layout-constrained wp-container-core-group-is-layout-40de7cf3 wp-block-group-is-layout-constrained\">\n\n<h3 class=\"wp-block-heading\">In this post<\/h3>\n\n\n\n<ul class=\"wp-block-list\">\n\n<li><a href=\"#the-problem-a-server-nobody-could-explain\">The problem: a server nobody could explain<\/a><\/li>\n\n\n<li><a href=\"#what-we-were-aiming-for\">What we were aiming for<\/a><\/li>\n\n\n<li><a href=\"#how-we-approached-it\">How we approached it<\/a><\/li>\n\n\n<li><a href=\"#what-we-built\">What we built<\/a><\/li>\n\n\n<li><a href=\"#challenges-we-hit\">Challenges we hit<\/a><\/li>\n\n\n<li><a href=\"#the-result\">The result<\/a><\/li>\n\n\n<li><a href=\"#do-it-yourself-a-gitops-repo-for-one-ai-server\">Do it yourself: a GitOps repo for one AI server<\/a><\/li>\n\n\n<li><a href=\"#key-takeaways\">Key takeaways<\/a><\/li>\n\n<\/ul>\n\n<\/div><\/div>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"the-problem-a-server-nobody-could-explain\">The problem: a server nobody could explain<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The lab server is the NVIDIA DGX Spark from <a href=\"https:\/\/catharsis.net.au\/blog\/why-run-ai-on-your-own-hardware\/\" target=\"_blank\" rel=\"noreferrer noopener\">Part 1<\/a>. A few weeks into testing it ran a couple of dozen containers, over a dozen models and four Docker Compose stacks, all set up by hand.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">That started to cost us. There had been three hard freezes in two weeks (<a href=\"https:\/\/catharsis.net.au\/blog\/local-ai-server-freezes-unified-memory\/\" target=\"_blank\" rel=\"noreferrer noopener\">Part 3<\/a> covers them). Shortly before them, the model server had been silently upgraded: someone recreated it from a <code>:latest<\/code> image in the container-management UI, which pulled a newer Ollama release, and the freezes followed. The same click pruned old images, including the one the OCR service needed. Nothing had a history or a rollback, and the off-box backup had stopped without anyone noticing. For a business, that&#8217;s the difference between a quick rollback and a day of guesswork.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"what-we-were-aiming-for\">What we were aiming for<\/h2>\n\n\n\n<ul class=\"wp-block-list\">\n\n<li>Lose nothing. No data, model or working configuration should disappear because of a change.<\/li>\n\n\n<li>Make every change reviewable and reversible, with a record of what changed, why and how to undo it.<\/li>\n\n\n<li>Let anyone with the repo, or any AI session working from it, understand the server without being able to break it by accident.<\/li>\n\n\n<li>Allow no surprises from outside the repo: no automatic upgrades and no silent backup failures.<\/li>\n\n<\/ul>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"how-we-approached-it\">How we approached it<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">On one server, a cluster orchestrator would add more moving parts than it removes. We needed history, review and rollback, which git already gives you: a repo as the single source of truth, a deploy script as the only way in, and nightly snapshots that expose any difference between the two.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The ground rules: remove nothing, only disable; keep the container UI view-only; recreate at most one service at a time; and require a <strong>backup gate<\/strong>, a verified off-box archive less than 24 hours old, before anything that could lose data.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">First we stopped the bleeding with small, logged changes. We pinned Ollama to the version already running, raised its keep-alive from 30 seconds to 5 minutes to cut model churn, and made the GPU-restore script check every GPU container, not only Ollama, every minute from cron. Why GPU access needs restoring at all is the first challenge below.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"what-we-built\">What we built<\/h2>\n\n\n\n<pre class=\"wp-block-code\"><code>   git repo (source of truth)\n        \u2502  pull\n        \u25bc\n   server checkout \u2500\u2500 deploy.sh &lt;stack&gt; \u2500\u2500\u25ba sync files into place \u2500\u2500\u25ba compose up (that stack only)\n        \u2502                                                         \u2514\u2500\u25ba verify GPU access\n        \u2514\u2500\u2500 nightly state snapshot \u2500\u2500\u25ba state\/*.txt \u2500\u2500\u25ba commit   (drift becomes visible)\n<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">A deploy script as the only way in<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">On the first day we copied every stack and the host configuration into the repo and hash-checked each file against the live copy. Adopting them recreated nothing. From then on <code>deploy.sh<\/code> was the only way configuration reaches the server. For one target it:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n\n<li>Pulls the repo, fast-forward only, failing loudly if the server&#8217;s copy has diverged.<\/li>\n\n\n<li>Checks for drift against the commit last deployed to that target, not the latest commit. A normal repo change shows as <em>pending<\/em>. A live file that differs from what was deployed means a hand edit, and the deploy stops. You either copy the live file into the repo or rerun with <code>--accept-drift<\/code>, which first saves the differing files to a pre-change folder.<\/li>\n\n\n<li>Plans with a compose dry run and refuses to recreate a container without <code>--allow-recreate<\/code> (&quot;this is an agreed window&quot;). <code>--no-recreate<\/code> is for config-only changes, and <code>--service &lt;name&gt;<\/code> limits a recreate to one service.<\/li>\n\n\n<li>Syncs files, never touching data directories, <code>.env<\/code> files or model weights, and refuses to run if a variable in the template is missing from the live <code>.env<\/code>.<\/li>\n\n\n<li>Verifies GPU access, then logs one line to a deploy log committed to the repo.<\/li>\n\n<\/ol>\n\n\n\n<h3 class=\"wp-block-heading\">Pinned images and visible updates<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Every image is pinned, and a check refuses floating tags. Before the pinning window we pulled every registry tag and confirmed it matched the image ID already running, so pinning couldn&#8217;t quietly upgrade anything, then recreated containers one at a time. Updates are visible but never automatic: an update checker reaches Docker through a <strong>socket proxy that allows read-only (GET) calls only<\/strong>, and can only propose a one-line tag bump for review.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Records, snapshots and pre-commit checks<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">A rules file at the repo root loads automatically in every AI coding session, each rule next to the incident that caused it. <code>INVENTORY.md<\/code> says what should run and why. <code>CHANGELOG.md<\/code> is append-only: what, why, who and how to roll back.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">A nightly snapshot writes what is <em>actually<\/em> running into <code>state\/<\/code> (containers with digests, models, ports, config-file hashes and workflows) and commits only on a real change. Exported test workflows must hold no literal secret, or the export fails.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Every commit also runs six checks, each for a mistake we had already made once:<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\">\n<thead>\n<tr>\n<th>Check<\/th>\n<th>Refuses<\/th>\n<th>Because<\/th>\n<\/tr>\n<\/thead>\n<tbody>\n<tr>\n<td>pins<\/td>\n<td>floating image tags<\/td>\n<td>the silent Ollama upgrade<\/td>\n<\/tr>\n<tr>\n<td>budgets<\/td>\n<td>a model deployment with no declared input budget<\/td>\n<td>the gateway&#8217;s admission check reads only that field (Part 4)<\/td>\n<\/tr>\n<tr>\n<td>approved models<\/td>\n<td>a model list that doesn&#8217;t resolve to a real model on a real instance<\/td>\n<td>a guard alerts on unapproved models<\/td>\n<\/tr>\n<tr>\n<td>egress<\/td>\n<td>any fallback from a local model to a cloud model<\/td>\n<td>nothing may silently leave the server (Part 2)<\/td>\n<\/tr>\n<tr>\n<td>YAML duplicates<\/td>\n<td>a duplicated key in dashboard YAML<\/td>\n<td>it took the dashboard down for about 10 minutes<\/td>\n<\/tr>\n<tr>\n<td>inventory<\/td>\n<td>compose files, dashboard tiles and the inventory disagreeing<\/td>\n<td>&quot;what runs&quot; must be the same in all three<\/td>\n<\/tr>\n<\/tbody>\n<\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">When a model was linked to the freezes, we renamed its tags to <code>\u2026-disabled<\/code> rather than deleting them, so bringing it back was a copy command, not a re-download. Retired services go into a compose profile called <code>disabled<\/code>.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Backups that fail loudly<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">We rebuilt the backup so it can restore onto a fresh machine without copying anything you can download again. Each archive carries a <em>restore manifest<\/em>: the NVIDIA driver and Container Toolkit versions, the Docker version, the package list and every Ollama tag&#8217;s Modelfile, so custom fixed-context tags can be rebuilt exactly. Our recommendation is the same for any server like this: run it nightly to a separate target, alert on any failed run, check integrity on a schedule, and test a restore before you rely on it.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Rules for AI coding agents<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">AI coding agents draft changes well, but they take the shortest path, which on a live server means editing files in place. So the safe path has to be the only one:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n\n<li>Scoped, unprivileged, reviewable access. Agents use an unprivileged account that can read the repo and propose changes; changes reach the server only through the deploy script, after review. No root and no shared admin accounts.<\/li>\n\n\n<li>No secrets in files the agent&#8217;s account can read. If an agent needs to test a model, it gets its own narrowly scoped gateway key.<\/li>\n\n\n<li>Immutable live files. The gateway config, its guard and every compose file carry <code>chattr +i<\/code>; <code>deploy.sh<\/code> lifts it only for its own sync and restores it on every exit path. This catches accidents; the access model is what enforces the rule.<\/li>\n\n\n<li>A server that explains its rules. A login banner and an <code>AGENTS.md<\/code> wherever an agent might start say the server is managed from a repo, and <em>if you can&#8217;t use the repo, stop and ask.<\/em><\/li>\n\n\n<li>Alerts that mean what they say. Admin-key calls are told apart from missing or malformed keys, and any config change inside a Docker volume, where <code>deploy.sh<\/code> can&#8217;t see it, raises an alert.<\/li>\n\n<\/ul>\n\n\n\n<div class=\"wp-block-group has-pale-cyan-blue-background-color has-background\" style=\"padding-top:28px;padding-right:32px;padding-bottom:28px;padding-left:32px;margin-top:32px;margin-bottom:32px\"><div class=\"wp-block-group__inner-container is-layout-constrained wp-container-core-group-is-layout-40de7cf3 wp-block-group-is-layout-constrained\">\n\n<p class=\"wp-block-paragraph\"><strong>Putting AI coding agents or AI tools to work?<\/strong> We set up AI security and governance: scoped access, policies and controls that let your team use AI without opening new risks.<\/p>\n\n\n\n<div class=\"wp-block-buttons is-layout-flex wp-block-buttons-is-layout-flex\">\n\n<div class=\"wp-block-button\"><a class=\"wp-block-button__link wp-element-button\" href=\"https:\/\/catharsis.net.au\/ai-security-governance\/?utm_source=blog&amp;utm_medium=article&amp;utm_campaign=local-ai-series&amp;utm_content=part-06-mid\" target=\"_blank\" rel=\"noreferrer noopener\">Explore AI Security &amp; Governance<\/a><\/div>\n\n<\/div>\n\n<\/div><\/div>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"challenges-we-hit\">Challenges we hit<\/h2>\n\n\n\n<h3 class=\"wp-block-heading\">A systemd reload takes the GPU away<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">On this server (cgroup v2, the systemd cgroup driver and the NVIDIA container runtime), <code>systemctl daemon-reload<\/code> removes GPU device permission from every running GPU container. So do <code>daemon-reexec<\/code>, <code>enable<\/code>, <code>disable<\/code> and <code>systemctl edit<\/code>, which reloads for you. It&#8217;s a known issue in the NVIDIA Container Toolkit. A process that already holds the GPU keeps working, but Ollama starts a new process for each model it loads, that process can&#8217;t reach the GPU, and the model quietly runs on the CPU. The symptoms are models at &quot;100% CPU&quot;, a load average of 10 to 20, and <code>Failed to initialize NVML<\/code> from <code>nvidia-smi<\/code> inside the container. Enabling a systemd timer for backups broke Ollama and the OCR API within 60 seconds.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">So we schedule with cron, and after any reload the GPU restore tests GPU access in each GPU container and restarts the ones that lost it, even mid-request. Early on, <code>deploy.sh host<\/code> reloaded systemd on every host deploy. One afternoon in week two, eight script-only host deploys restarted Ollama eight times, and one restart turned a test caller&#8217;s request into an Ollama 500 error; the gateway retried, and the answer took 28 seconds instead of about 20. The deploy now reloads systemd only when a file under the systemd folders changed.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The OS can trigger this too. In week three an automatic package refresh ran its own <code>daemon-reload<\/code>, and the autoheal restarted three services. Automatic refreshes, security updates included, now wait for a maintenance window.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Tools that reported success when they hadn&#8217;t checked<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The GPU restore reported success whenever its cron copy held the lock. Now it says &quot;checked nothing&quot;, and the deploy verifies GPU access itself.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Deploys that interleaved or didn&#8217;t take effect<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Parallel deploys interleaved, so the script now takes a lock. And a config-only deploy didn&#8217;t always apply: when a file is bind-mounted <em>as a single file<\/em>, the sync replaces it with a rename (a new inode) and the container keeps seeing the old content. Those containers need a restart, in the right order.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Drift alerts don&#8217;t stop anyone<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Drift detection notices a hand edit after the fact but doesn&#8217;t prevent one, and an AI agent never reads the ops chat. So the agent rules rely on permissions, not alerts.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"the-result\">The result<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">We tested the loop with a deliberate hand edit: it showed up in the next state commit, the next deploy refused to run, and <code>--accept-drift --no-recreate<\/code> put it right. Pinning every image changed nothing that was running. Leaving model weights out made the backup archive about a third smaller.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Three days in, we updated the host&#8217;s OS, kernel, NVIDIA driver and Container Toolkit, mainly for better memory handling, and treated it like any change: a fresh backup, the GPU idle, the before-state saved and the old kernel kept as a fallback. Afterwards every GPU container had GPU access, dry runs showed no drift, and it soaked for three days before the next change.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The cost is speed: a one-line change is now a commit, six checks, a review, a dry run and a deploy, which is still far cheaper than recovering from a freeze. Two gaps remain: configuration inside Docker volumes is invisible to <code>deploy.sh<\/code>, and GPU injection should move to the toolkit&#8217;s Container Device Interface (CDI) mode, which avoids the reload problem instead of repairing it.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"do-it-yourself-a-gitops-repo-for-one-ai-server\">Do it yourself: a GitOps repo for one AI server<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>What you need:<\/strong> one Linux server running Docker Compose 2.17 or later (for <code>--dry-run<\/code>), with the NVIDIA Container Toolkit if you use NVIDIA GPUs, a private Git remote, and about an afternoon.<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n\n<li>\n<p><strong>Lay out the repo<\/strong>, one folder per stack plus host config and state:<\/p>\n<pre><code>stacks\/&lt;stack&gt;\/compose.yaml   stacks\/&lt;stack&gt;\/.env.template\nhost\/cron.d\/   host\/systemd\/   scripts\/deploy.sh   state\/   AGENTS.md   CHANGELOG.md\n<\/code><\/pre>\n<p><em>Check:<\/em> after copying the live files in, <code>diff -r<\/code> against the live folder shows nothing.<\/p>\n<\/li>\n\n\n<li>\n<p><strong>Pin every image by digest<\/strong>, using what is already running. Record each container&#8217;s image ID first, so you can prove the pin changed nothing:<\/p>\n<pre><code class=\"language-bash\">docker inspect --format '{{.Image}}' ollama &gt; before-ollama.txt\ndocker image inspect --format '{{index .RepoDigests 0}}' &quot;ollama\/ollama:$TAG&quot;   # TAG = the tag running now\n# compose.yaml \u2192  image: ollama\/ollama@sha256:&lt;digest&gt;\n<\/code><\/pre>\n<p>Changing <code>image:<\/code> is a config change, so Compose will recreate the container; do it one service at a time in a maintenance window.\n<em>Check:<\/em> <code>docker compose config --images<\/code> lists only <code>@sha256:<\/code> references, and after the recreate <code>docker inspect --format '{{.Image}}' ollama<\/code> prints the same image ID as <code>before-ollama.txt<\/code>.<\/p>\n<\/li>\n\n\n<li>\n<p><strong>Record what was deployed.<\/strong> At the end of each deploy, write the commit to <code>state\/deployed-&lt;stack&gt;<\/code>, and check drift against it:<\/p>\n<pre><code class=\"language-bash\">S=mystack   # the stack's folder name\ngit show &quot;$(cat &quot;state\/deployed-$S&quot;):stacks\/$S\/compose.yaml&quot; | diff -q - &quot;\/srv\/$S\/compose.yaml&quot;\n<\/code><\/pre>\n<p><em>Check:<\/em> hand-edit the live file, and the deploy stops and names it.<\/p>\n<\/li>\n\n\n<li>\n<p><strong>Deploy one stack at a time:<\/strong> <code>git pull --ff-only<\/code>, the drift check, <code>docker compose --dry-run up -d<\/code>, the sync, then <code>docker compose up -d --no-recreate<\/code> unless a recreate was explicitly allowed. Wrap it in <code>flock<\/code> so two deploys can&#8217;t interleave.\n<em>Check:<\/em> running the same deploy twice reports nothing to change.<\/p>\n<\/li>\n\n\n<li>\n<p><strong>Reload systemd only when units changed<\/strong>, then verify the GPU (NVIDIA example):<\/p>\n<pre><code class=\"language-bash\">git diff --name-only &quot;$OLD&quot; &quot;$NEW&quot; | grep -q '^host\/systemd\/' &amp;&amp; sudo systemctl daemon-reload\ndocker exec &lt;gpu-container&gt; nvidia-smi -L\n<\/code><\/pre>\n<p><em>Check:<\/em> <code>nvidia-smi -L<\/code> lists the GPU in every GPU container after a host deploy. <code>Failed to initialize NVML<\/code> means the reload took GPU access away; restart that container.<\/p>\n<\/li>\n\n\n<li>\n<p><strong>Snapshot nightly from cron.<\/strong> Write <code>docker ps --format '{{.Names}} {{.Image}}'<\/code>, <code>docker exec ollama ollama list<\/code> and <code>sha256sum<\/code> of the live config into <code>state\/<\/code>, and commit only if <code>git status --porcelain state\/<\/code> prints something. Cron has a minimal environment, so set <code>PATH<\/code> in the script, commit with <code>git -c user.name=state-bot -c user.email=&lt;address&gt;<\/code>, and push with a deploy key that can write only to this repo.\n<em>Check:<\/em> a quiet night produces no commit; a hand edit shows up the next morning.<\/p>\n<\/li>\n\n\n<li>\n<p><strong>Add a pre-commit hook<\/strong> (<code>.git\/hooks\/pre-commit<\/code>, executable) that refuses private keys and any image not pinned by digest, and lets clean commits through:<\/p>\n<pre><code class=\"language-bash\">#!\/bin\/sh\nadded=$(git diff --cached -U0 | grep '^+')\nif printf '%s\\n' &quot;$added&quot; | grep -Eq 'BEGIN [A-Z ]*PRIVATE KEY'; then\n  echo &quot;refused: private key in commit&quot; &gt;&amp;2; exit 1\nfi\nif printf '%s\\n' &quot;$added&quot; | grep -E '^\\+[[:space:]]*image:' | grep -vq '@sha256:'; then\n  echo &quot;refused: image not pinned by digest&quot; &gt;&amp;2; exit 1\nfi\nexit 0\n<\/code><\/pre>\n<p><em>Check:<\/em> a compose file with <code>image: redis:7<\/code> or a bare <code>image: nginx<\/code> is rejected, and an ordinary commit with no image changes goes through.<\/p>\n<\/li>\n\n\n<li>\n<p><strong>Fence in AI agents.<\/strong> Create an unprivileged user that can read the repo only, keep <code>.env<\/code> files at mode <code>600<\/code> owned by another account, set <code>chattr +i<\/code> (as root) on live config, and write the rules in <code>AGENTS.md<\/code>.\n<em>Check:<\/em> as that user, appending to a live config file fails with &quot;Operation not permitted&quot;, and reading a <code>.env<\/code> fails with &quot;Permission denied&quot;.<\/p>\n<\/li>\n\n<\/ol>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"key-takeaways\">Key takeaways<\/h2>\n\n\n\n<ul class=\"wp-block-list\">\n\n<li>Pin every image, and compare image IDs before and after so you know pinning didn&#8217;t change anything.<\/li>\n\n\n<li>Detect drift against what was last deployed, not the latest commit, so you can tell a hand edit from a pending change.<\/li>\n\n\n<li>On a GPU server, treat a systemd reload as a production event. Reload only when systemd files change, and check GPU access afterwards.<\/li>\n\n\n<li>Disable and label instead of deleting, so rolling back is a rename rather than a re-download.<\/li>\n\n\n<li>Make backup failure loud, check the backup&#8217;s age before risky work, and test a restore before you rely on it.<\/li>\n\n\n<li>Alerts aren&#8217;t enough for AI agents. Give them scoped, unprivileged accounts, keep secrets out of their reach, and send every change through the repo.<\/li>\n\n<\/ul>\n\n\n\n<div class=\"wp-block-group has-light-green-cyan-background-color has-background\" style=\"padding-top:28px;padding-right:32px;padding-bottom:28px;padding-left:32px;margin-top:40px;margin-bottom:32px\"><div class=\"wp-block-group__inner-container is-layout-constrained wp-container-core-group-is-layout-40de7cf3 wp-block-group-is-layout-constrained\">\n\n<h2 class=\"wp-block-heading\">Planning private AI?<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Catharsis Cyber Security is a Sydney cyber security company. We design, build and secure private AI platforms like the one in this lab, and help you decide where local or cloud AI fits.<\/p>\n\n\n\n<div class=\"wp-block-buttons is-layout-flex wp-block-buttons-is-layout-flex\">\n\n<div class=\"wp-block-button\"><a class=\"wp-block-button__link wp-element-button\" href=\"https:\/\/catharsis.net.au\/contact-us\/?type=contact&amp;service=ai-security-governance&amp;utm_source=blog&amp;utm_medium=article&amp;utm_campaign=local-ai-series&amp;utm_content=part-06-contact\" target=\"_blank\" rel=\"noreferrer noopener\">Talk to us about AI<\/a><\/div>\n\n\n<div class=\"wp-block-button is-style-outline is-style-outline--1\"><a class=\"wp-block-button__link wp-element-button\" href=\"https:\/\/catharsis.net.au\/secure-ai\/?utm_source=blog&amp;utm_medium=article&amp;utm_campaign=local-ai-series&amp;utm_content=part-06-end\" target=\"_blank\" rel=\"noreferrer noopener\">Explore Secure AI<\/a><\/div>\n\n<\/div>\n\n<\/div><\/div>\n\n\n\n<div class=\"wp-block-group has-background\" style=\"background-color:#f4f6f8;padding-top:28px;padding-right:32px;padding-bottom:28px;padding-left:32px\"><div class=\"wp-block-group__inner-container is-layout-constrained wp-container-core-group-is-layout-40de7cf3 wp-block-group-is-layout-constrained\">\n\n<p class=\"wp-block-paragraph\"><strong>Keep reading the series<\/strong><\/p>\n\n\n\n<div class=\"wp-block-buttons is-layout-flex wp-block-buttons-is-layout-flex\">\n\n<div class=\"wp-block-button is-style-outline is-style-outline--2\"><a class=\"wp-block-button__link wp-element-button\" href=\"https:\/\/catharsis.net.au\/blog\/smart-routing-single-gpu-local-llm\/\" target=\"_blank\" rel=\"noreferrer noopener\">\u2190 Part 5<\/a><\/div>\n\n\n<div class=\"wp-block-button is-style-outline is-style-outline--3\"><a class=\"wp-block-button__link wp-element-button\" href=\"https:\/\/catharsis.net.au\/blog\/running-local-ai-in-production-series-guide\/\" target=\"_blank\" rel=\"noreferrer noopener\">Series guide<\/a><\/div>\n\n\n<div class=\"wp-block-button\"><a class=\"wp-block-button__link wp-element-button\" href=\"https:\/\/catharsis.net.au\/blog\/observability-for-local-ai\/\" target=\"_blank\" rel=\"noreferrer noopener\">Next: Part 7 \u2192<\/a><\/div>\n\n<\/div>\n\n<\/div><\/div>\n","protected":false},"excerpt":{"rendered":"<p>A silent image upgrade, a pruned container image and a backup that had quietly stopped pushed us to run our lab AI server from a git repo. Here is what we built, the GPU trap we fell into, how to set up AI coding agents safely, and the steps to build the same thing yourself.<\/p>\n","protected":false},"author":2,"featured_media":1098,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[69],"tags":[96,103,104,102,105,106,75,80],"class_list":["post-1099","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-ai","tag-ai-agents","tag-devops","tag-docker","tag-gitops","tag-infrastructure-as-code","tag-lab-build","tag-local-llm","tag-ollama"],"yoast_head":"<!-- This site is optimized with the Yoast SEO plugin v28.8 - https:\/\/yoast.com\/product\/yoast-seo-wordpress\/ -->\n<title>How Do You Keep an AI Server Stable? GitOps for Local AI (Part 6 of 11) - Catharsis | Blog<\/title>\n<meta name=\"description\" content=\"How do you keep an AI server stable? Run it from git: one deploy script, pinned images, drift checks, safe GPU reloads, loud backups and scoped AI agents.\" \/>\n<meta name=\"robots\" content=\"index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1\" \/>\n<link rel=\"canonical\" href=\"https:\/\/catharsis.net.au\/blog\/gitops-for-a-gpu-ai-server\/\" \/>\n<meta property=\"og:locale\" content=\"en_US\" \/>\n<meta property=\"og:type\" content=\"article\" \/>\n<meta property=\"og:title\" content=\"How Do You Keep an AI Server Stable? GitOps for Local AI (Part 6 of 11) - Catharsis | Blog\" \/>\n<meta property=\"og:description\" content=\"How do you keep an AI server stable? Run it from git: one deploy script, pinned images, drift checks, safe GPU reloads, loud backups and scoped AI agents.\" \/>\n<meta property=\"og:url\" content=\"https:\/\/catharsis.net.au\/blog\/gitops-for-a-gpu-ai-server\/\" \/>\n<meta property=\"og:site_name\" content=\"Catharsis | Blog\" \/>\n<meta property=\"article:published_time\" content=\"2026-09-20T23:30:00+00:00\" \/>\n<meta property=\"article:modified_time\" content=\"2026-11-03T09:28:01+00:00\" \/>\n<meta property=\"og:image\" content=\"https:\/\/catharsis.net.au\/blog\/wp-content\/uploads\/2026\/11\/local-ai-series-part-06.jpg\" \/>\n\t<meta property=\"og:image:width\" content=\"1280\" \/>\n\t<meta property=\"og:image:height\" content=\"720\" \/>\n\t<meta property=\"og:image:type\" content=\"image\/jpeg\" \/>\n<meta name=\"author\" content=\"Sajal Jain\" \/>\n<meta name=\"twitter:card\" content=\"summary_large_image\" \/>\n<meta name=\"twitter:label1\" content=\"Written by\" \/>\n\t<meta name=\"twitter:data1\" content=\"Sajal Jain\" \/>\n\t<meta name=\"twitter:label2\" content=\"Est. reading time\" \/>\n\t<meta name=\"twitter:data2\" content=\"18 minutes\" \/>\n<script type=\"application\/ld+json\" class=\"yoast-schema-graph\">{\"@context\":\"https:\\\/\\\/schema.org\",\"@graph\":[{\"@type\":\"Article\",\"@id\":\"https:\\\/\\\/catharsis.net.au\\\/blog\\\/gitops-for-a-gpu-ai-server\\\/#article\",\"isPartOf\":{\"@id\":\"https:\\\/\\\/catharsis.net.au\\\/blog\\\/gitops-for-a-gpu-ai-server\\\/\"},\"author\":{\"name\":\"Sajal Jain\",\"@id\":\"https:\\\/\\\/catharsis.net.au\\\/blog\\\/#\\\/schema\\\/person\\\/f0826efb92a7aaf8d46b24715990e099\"},\"headline\":\"How Do You Keep an AI Server Stable? GitOps for Local AI (Part 6 of 11)\",\"datePublished\":\"2026-09-20T23:30:00+00:00\",\"dateModified\":\"2026-11-03T09:28:01+00:00\",\"mainEntityOfPage\":{\"@id\":\"https:\\\/\\\/catharsis.net.au\\\/blog\\\/gitops-for-a-gpu-ai-server\\\/\"},\"wordCount\":3284,\"commentCount\":0,\"publisher\":{\"@id\":\"https:\\\/\\\/catharsis.net.au\\\/blog\\\/#organization\"},\"image\":{\"@id\":\"https:\\\/\\\/catharsis.net.au\\\/blog\\\/gitops-for-a-gpu-ai-server\\\/#primaryimage\"},\"thumbnailUrl\":\"https:\\\/\\\/catharsis.net.au\\\/blog\\\/wp-content\\\/uploads\\\/2026\\\/11\\\/local-ai-series-part-06.jpg\",\"keywords\":[\"AI agents\",\"DevOps\",\"Docker\",\"GitOps\",\"infrastructure as code\",\"lab build\",\"local LLM\",\"Ollama\"],\"articleSection\":[\"Artificial Intelligence\"],\"inLanguage\":\"en-US\",\"potentialAction\":[{\"@type\":\"CommentAction\",\"name\":\"Comment\",\"target\":[\"https:\\\/\\\/catharsis.net.au\\\/blog\\\/gitops-for-a-gpu-ai-server\\\/#respond\"]}]},{\"@type\":\"WebPage\",\"@id\":\"https:\\\/\\\/catharsis.net.au\\\/blog\\\/gitops-for-a-gpu-ai-server\\\/\",\"url\":\"https:\\\/\\\/catharsis.net.au\\\/blog\\\/gitops-for-a-gpu-ai-server\\\/\",\"name\":\"How Do You Keep an AI Server Stable? GitOps for Local AI (Part 6 of 11) - Catharsis | Blog\",\"isPartOf\":{\"@id\":\"https:\\\/\\\/catharsis.net.au\\\/blog\\\/#website\"},\"primaryImageOfPage\":{\"@id\":\"https:\\\/\\\/catharsis.net.au\\\/blog\\\/gitops-for-a-gpu-ai-server\\\/#primaryimage\"},\"image\":{\"@id\":\"https:\\\/\\\/catharsis.net.au\\\/blog\\\/gitops-for-a-gpu-ai-server\\\/#primaryimage\"},\"thumbnailUrl\":\"https:\\\/\\\/catharsis.net.au\\\/blog\\\/wp-content\\\/uploads\\\/2026\\\/11\\\/local-ai-series-part-06.jpg\",\"datePublished\":\"2026-09-20T23:30:00+00:00\",\"dateModified\":\"2026-11-03T09:28:01+00:00\",\"description\":\"How do you keep an AI server stable? Run it from git: one deploy script, pinned images, drift checks, safe GPU reloads, loud backups and scoped AI agents.\",\"breadcrumb\":{\"@id\":\"https:\\\/\\\/catharsis.net.au\\\/blog\\\/gitops-for-a-gpu-ai-server\\\/#breadcrumb\"},\"inLanguage\":\"en-US\",\"potentialAction\":[{\"@type\":\"ReadAction\",\"target\":[\"https:\\\/\\\/catharsis.net.au\\\/blog\\\/gitops-for-a-gpu-ai-server\\\/\"]}]},{\"@type\":\"ImageObject\",\"inLanguage\":\"en-US\",\"@id\":\"https:\\\/\\\/catharsis.net.au\\\/blog\\\/gitops-for-a-gpu-ai-server\\\/#primaryimage\",\"url\":\"https:\\\/\\\/catharsis.net.au\\\/blog\\\/wp-content\\\/uploads\\\/2026\\\/11\\\/local-ai-series-part-06.jpg\",\"contentUrl\":\"https:\\\/\\\/catharsis.net.au\\\/blog\\\/wp-content\\\/uploads\\\/2026\\\/11\\\/local-ai-series-part-06.jpg\",\"width\":1280,\"height\":720,\"caption\":\"A rack-mounted server chassis\"},{\"@type\":\"BreadcrumbList\",\"@id\":\"https:\\\/\\\/catharsis.net.au\\\/blog\\\/gitops-for-a-gpu-ai-server\\\/#breadcrumb\",\"itemListElement\":[{\"@type\":\"ListItem\",\"position\":1,\"name\":\"Home\",\"item\":\"https:\\\/\\\/catharsis.net.au\\\/blog\\\/\"},{\"@type\":\"ListItem\",\"position\":2,\"name\":\"How Do You Keep an AI Server Stable? GitOps for Local AI (Part 6 of 11)\"}]},{\"@type\":\"WebSite\",\"@id\":\"https:\\\/\\\/catharsis.net.au\\\/blog\\\/#website\",\"url\":\"https:\\\/\\\/catharsis.net.au\\\/blog\\\/\",\"name\":\"Catharsis | Blog\",\"description\":\"\",\"publisher\":{\"@id\":\"https:\\\/\\\/catharsis.net.au\\\/blog\\\/#organization\"},\"potentialAction\":[{\"@type\":\"SearchAction\",\"target\":{\"@type\":\"EntryPoint\",\"urlTemplate\":\"https:\\\/\\\/catharsis.net.au\\\/blog\\\/?s={search_term_string}\"},\"query-input\":{\"@type\":\"PropertyValueSpecification\",\"valueRequired\":true,\"valueName\":\"search_term_string\"}}],\"inLanguage\":\"en-US\"},{\"@type\":\"Organization\",\"@id\":\"https:\\\/\\\/catharsis.net.au\\\/blog\\\/#organization\",\"name\":\"Catharsis | Blog\",\"url\":\"https:\\\/\\\/catharsis.net.au\\\/blog\\\/\",\"logo\":{\"@type\":\"ImageObject\",\"inLanguage\":\"en-US\",\"@id\":\"https:\\\/\\\/catharsis.net.au\\\/blog\\\/#\\\/schema\\\/logo\\\/image\\\/\",\"url\":\"https:\\\/\\\/catharsis.net.au\\\/blog\\\/wp-content\\\/uploads\\\/2019\\\/01\\\/catharsis_au.jpg\",\"contentUrl\":\"https:\\\/\\\/catharsis.net.au\\\/blog\\\/wp-content\\\/uploads\\\/2019\\\/01\\\/catharsis_au.jpg\",\"width\":215,\"height\":60,\"caption\":\"Catharsis | Blog\"},\"image\":{\"@id\":\"https:\\\/\\\/catharsis.net.au\\\/blog\\\/#\\\/schema\\\/logo\\\/image\\\/\"}},{\"@type\":\"Person\",\"@id\":\"https:\\\/\\\/catharsis.net.au\\\/blog\\\/#\\\/schema\\\/person\\\/f0826efb92a7aaf8d46b24715990e099\",\"name\":\"Sajal Jain\",\"image\":{\"@type\":\"ImageObject\",\"inLanguage\":\"en-US\",\"@id\":\"https:\\\/\\\/catharsis.net.au\\\/blog\\\/wp-content\\\/uploads\\\/2019\\\/01\\\/Sajal-copy-96x96.jpg\",\"url\":\"https:\\\/\\\/catharsis.net.au\\\/blog\\\/wp-content\\\/uploads\\\/2019\\\/01\\\/Sajal-copy-96x96.jpg\",\"contentUrl\":\"https:\\\/\\\/catharsis.net.au\\\/blog\\\/wp-content\\\/uploads\\\/2019\\\/01\\\/Sajal-copy-96x96.jpg\",\"caption\":\"Sajal Jain\"},\"description\":\"Cyber Security Enthusiast | OSCP | CEH | CHFI | CNSS | Master of IT Security &amp; Master of IT Management\",\"url\":\"https:\\\/\\\/catharsis.net.au\\\/blog\\\/author\\\/sajal-jain\\\/\"}]}<\/script>\n<!-- \/ Yoast SEO plugin. -->","yoast_head_json":{"title":"How Do You Keep an AI Server Stable? GitOps for Local AI (Part 6 of 11) - Catharsis | Blog","description":"How do you keep an AI server stable? Run it from git: one deploy script, pinned images, drift checks, safe GPU reloads, loud backups and scoped AI agents.","robots":{"index":"index","follow":"follow","max-snippet":"max-snippet:-1","max-image-preview":"max-image-preview:large","max-video-preview":"max-video-preview:-1"},"canonical":"https:\/\/catharsis.net.au\/blog\/gitops-for-a-gpu-ai-server\/","og_locale":"en_US","og_type":"article","og_title":"How Do You Keep an AI Server Stable? GitOps for Local AI (Part 6 of 11) - Catharsis | Blog","og_description":"How do you keep an AI server stable? Run it from git: one deploy script, pinned images, drift checks, safe GPU reloads, loud backups and scoped AI agents.","og_url":"https:\/\/catharsis.net.au\/blog\/gitops-for-a-gpu-ai-server\/","og_site_name":"Catharsis | Blog","article_published_time":"2026-09-20T23:30:00+00:00","article_modified_time":"2026-11-03T09:28:01+00:00","og_image":[{"width":1280,"height":720,"url":"https:\/\/catharsis.net.au\/blog\/wp-content\/uploads\/2026\/11\/local-ai-series-part-06.jpg","type":"image\/jpeg"}],"author":"Sajal Jain","twitter_card":"summary_large_image","twitter_misc":{"Written by":"Sajal Jain","Est. reading time":"18 minutes"},"schema":{"@context":"https:\/\/schema.org","@graph":[{"@type":"Article","@id":"https:\/\/catharsis.net.au\/blog\/gitops-for-a-gpu-ai-server\/#article","isPartOf":{"@id":"https:\/\/catharsis.net.au\/blog\/gitops-for-a-gpu-ai-server\/"},"author":{"name":"Sajal Jain","@id":"https:\/\/catharsis.net.au\/blog\/#\/schema\/person\/f0826efb92a7aaf8d46b24715990e099"},"headline":"How Do You Keep an AI Server Stable? GitOps for Local AI (Part 6 of 11)","datePublished":"2026-09-20T23:30:00+00:00","dateModified":"2026-11-03T09:28:01+00:00","mainEntityOfPage":{"@id":"https:\/\/catharsis.net.au\/blog\/gitops-for-a-gpu-ai-server\/"},"wordCount":3284,"commentCount":0,"publisher":{"@id":"https:\/\/catharsis.net.au\/blog\/#organization"},"image":{"@id":"https:\/\/catharsis.net.au\/blog\/gitops-for-a-gpu-ai-server\/#primaryimage"},"thumbnailUrl":"https:\/\/catharsis.net.au\/blog\/wp-content\/uploads\/2026\/11\/local-ai-series-part-06.jpg","keywords":["AI agents","DevOps","Docker","GitOps","infrastructure as code","lab build","local LLM","Ollama"],"articleSection":["Artificial Intelligence"],"inLanguage":"en-US","potentialAction":[{"@type":"CommentAction","name":"Comment","target":["https:\/\/catharsis.net.au\/blog\/gitops-for-a-gpu-ai-server\/#respond"]}]},{"@type":"WebPage","@id":"https:\/\/catharsis.net.au\/blog\/gitops-for-a-gpu-ai-server\/","url":"https:\/\/catharsis.net.au\/blog\/gitops-for-a-gpu-ai-server\/","name":"How Do You Keep an AI Server Stable? GitOps for Local AI (Part 6 of 11) - Catharsis | Blog","isPartOf":{"@id":"https:\/\/catharsis.net.au\/blog\/#website"},"primaryImageOfPage":{"@id":"https:\/\/catharsis.net.au\/blog\/gitops-for-a-gpu-ai-server\/#primaryimage"},"image":{"@id":"https:\/\/catharsis.net.au\/blog\/gitops-for-a-gpu-ai-server\/#primaryimage"},"thumbnailUrl":"https:\/\/catharsis.net.au\/blog\/wp-content\/uploads\/2026\/11\/local-ai-series-part-06.jpg","datePublished":"2026-09-20T23:30:00+00:00","dateModified":"2026-11-03T09:28:01+00:00","description":"How do you keep an AI server stable? Run it from git: one deploy script, pinned images, drift checks, safe GPU reloads, loud backups and scoped AI agents.","breadcrumb":{"@id":"https:\/\/catharsis.net.au\/blog\/gitops-for-a-gpu-ai-server\/#breadcrumb"},"inLanguage":"en-US","potentialAction":[{"@type":"ReadAction","target":["https:\/\/catharsis.net.au\/blog\/gitops-for-a-gpu-ai-server\/"]}]},{"@type":"ImageObject","inLanguage":"en-US","@id":"https:\/\/catharsis.net.au\/blog\/gitops-for-a-gpu-ai-server\/#primaryimage","url":"https:\/\/catharsis.net.au\/blog\/wp-content\/uploads\/2026\/11\/local-ai-series-part-06.jpg","contentUrl":"https:\/\/catharsis.net.au\/blog\/wp-content\/uploads\/2026\/11\/local-ai-series-part-06.jpg","width":1280,"height":720,"caption":"A rack-mounted server chassis"},{"@type":"BreadcrumbList","@id":"https:\/\/catharsis.net.au\/blog\/gitops-for-a-gpu-ai-server\/#breadcrumb","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"https:\/\/catharsis.net.au\/blog\/"},{"@type":"ListItem","position":2,"name":"How Do You Keep an AI Server Stable? GitOps for Local AI (Part 6 of 11)"}]},{"@type":"WebSite","@id":"https:\/\/catharsis.net.au\/blog\/#website","url":"https:\/\/catharsis.net.au\/blog\/","name":"Catharsis | Blog","description":"","publisher":{"@id":"https:\/\/catharsis.net.au\/blog\/#organization"},"potentialAction":[{"@type":"SearchAction","target":{"@type":"EntryPoint","urlTemplate":"https:\/\/catharsis.net.au\/blog\/?s={search_term_string}"},"query-input":{"@type":"PropertyValueSpecification","valueRequired":true,"valueName":"search_term_string"}}],"inLanguage":"en-US"},{"@type":"Organization","@id":"https:\/\/catharsis.net.au\/blog\/#organization","name":"Catharsis | Blog","url":"https:\/\/catharsis.net.au\/blog\/","logo":{"@type":"ImageObject","inLanguage":"en-US","@id":"https:\/\/catharsis.net.au\/blog\/#\/schema\/logo\/image\/","url":"https:\/\/catharsis.net.au\/blog\/wp-content\/uploads\/2019\/01\/catharsis_au.jpg","contentUrl":"https:\/\/catharsis.net.au\/blog\/wp-content\/uploads\/2019\/01\/catharsis_au.jpg","width":215,"height":60,"caption":"Catharsis | Blog"},"image":{"@id":"https:\/\/catharsis.net.au\/blog\/#\/schema\/logo\/image\/"}},{"@type":"Person","@id":"https:\/\/catharsis.net.au\/blog\/#\/schema\/person\/f0826efb92a7aaf8d46b24715990e099","name":"Sajal Jain","image":{"@type":"ImageObject","inLanguage":"en-US","@id":"https:\/\/catharsis.net.au\/blog\/wp-content\/uploads\/2019\/01\/Sajal-copy-96x96.jpg","url":"https:\/\/catharsis.net.au\/blog\/wp-content\/uploads\/2019\/01\/Sajal-copy-96x96.jpg","contentUrl":"https:\/\/catharsis.net.au\/blog\/wp-content\/uploads\/2019\/01\/Sajal-copy-96x96.jpg","caption":"Sajal Jain"},"description":"Cyber Security Enthusiast | OSCP | CEH | CHFI | CNSS | Master of IT Security &amp; Master of IT Management","url":"https:\/\/catharsis.net.au\/blog\/author\/sajal-jain\/"}]}},"_links":{"self":[{"href":"https:\/\/catharsis.net.au\/blog\/wp-json\/wp\/v2\/posts\/1099","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/catharsis.net.au\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/catharsis.net.au\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/catharsis.net.au\/blog\/wp-json\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/catharsis.net.au\/blog\/wp-json\/wp\/v2\/comments?post=1099"}],"version-history":[{"count":4,"href":"https:\/\/catharsis.net.au\/blog\/wp-json\/wp\/v2\/posts\/1099\/revisions"}],"predecessor-version":[{"id":1219,"href":"https:\/\/catharsis.net.au\/blog\/wp-json\/wp\/v2\/posts\/1099\/revisions\/1219"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/catharsis.net.au\/blog\/wp-json\/wp\/v2\/media\/1098"}],"wp:attachment":[{"href":"https:\/\/catharsis.net.au\/blog\/wp-json\/wp\/v2\/media?parent=1099"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/catharsis.net.au\/blog\/wp-json\/wp\/v2\/categories?post=1099"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/catharsis.net.au\/blog\/wp-json\/wp\/v2\/tags?post=1099"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}