{"id":2589,"date":"2026-06-01T11:15:49","date_gmt":"2026-06-01T11:15:49","guid":{"rendered":"https:\/\/www.mainmind.com\/blog\/?p=2589"},"modified":"2026-05-21T11:26:05","modified_gmt":"2026-05-21T11:26:05","slug":"azure-devops-backup-tool","status":"publish","type":"post","link":"https:\/\/www.mainmind.com\/blog\/azure-devops-backup-tool\/","title":{"rendered":"Azure DevOps backup tool"},"content":{"rendered":"\n<p class=\"wp-block-paragraph\">Hace unos meses me empez\u00f3 a preocupar algo evidente y sin embargo invisible: <strong>que sucede si tu c\u00f3digo vive solo en Azure DevOps<\/strong>. Repositorios, pipelines, wikis. Si ma\u00f1ana Microsoft tuviera un mal d\u00eda \u2014 o nuestra cuenta uno peor \u2014 perder\u00edamos a\u00f1os de trabajo. Y aunque Microsoft tiene SLAs estupendos, \u00abtener una copia de tu propio c\u00f3digo en tu propia casa\u00bb no es paranoia: es higiene b\u00e1sica.<\/p>\n\n\n\n<!--more-->\n\n\n\n<p class=\"wp-block-paragraph\">Por eso he liberado <strong>ADO Backup Tool<\/strong>, una herramienta dockerizada que hace exactamente eso: backup autom\u00e1tico y desatendido de Azure DevOps a un volumen local (o a tu NAS). El c\u00f3digo est\u00e1 en GitHub bajo licencia MIT.<\/p>\n\n\n\n<div class=\"wp-block-buttons is-layout-flex wp-block-buttons-is-layout-flex\">\n<div class=\"wp-block-button is-style-fill\"><a class=\"wp-block-button__link wp-element-button\" href=\"https:\/\/github.com\/mainmind83\/ado-backup\">Ver en GitHub \u2192<\/a><\/div>\n<\/div>\n\n\n\n<h2 class=\"wp-block-heading\">Qu\u00e9 hace<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Un \u00fanico contenedor que corre permanentemente en tu NAS (o en cualquier m\u00e1quina con Docker). Internamente lleva un planificador cron y, en cada ejecuci\u00f3n, vuelca tres cosas:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>Repositorios Git<\/strong> como <em>bare mirrors<\/em> (<code>git clone --mirror<\/code>), con actualizaci\u00f3n incremental \u2014 no reclona el historial entero cada noche.<\/li>\n\n\n\n<li><strong>Definiciones de pipelines<\/strong> (build y release) exportadas como JSON individuales.<\/li>\n\n\n\n<li><strong>Wikis<\/strong> con sus metadatos y todas las p\u00e1ginas en markdown.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">Todo el comportamiento se controla con un \u00fanico <code>config.yaml<\/code> de unas 25 l\u00edneas: qu\u00e9 organizaci\u00f3n, qu\u00e9 proyectos (o <code>[\"*\"]<\/code> para todos), cu\u00e1ndo correr (cron est\u00e1ndar), cu\u00e1ntos d\u00edas retener, qu\u00e9 recursos incluir.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Por qu\u00e9 bare mirrors y no checkouts<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">La decisi\u00f3n menos obvia del proyecto. Un <code>git clone --mirror<\/code> guarda toda la base de datos de git \u2014 ramas, tags, historia completa \u2014 sin desplegar el \u00e1rbol de trabajo. Ocupa menos, no se rompe si alguien toca archivos por accidente, y permite restaurar <em>cualquier<\/em> commit, <em>cualquier<\/em> rama, en el momento que quieras:<\/p>\n\n\n<div class=\"wp-block-syntaxhighlighter-code \"><pre class=\"brush: bash; title: ; notranslate\" title=\"\">\ngit clone \/backup\/2026-05-21T020000\/Project1\/git\/RepoA.git restaurado\ncd restaurado\ngit checkout main\n<\/pre><\/div>\n\n\n<p class=\"wp-block-paragraph\">Y los runs sucesivos son r\u00e1pidos: el bare mirror anterior se copia adelante y se refresca con <code>git remote update<\/code>, que solo descarga los objetos nuevos.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Despliegue en QNAP Container Station<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Yo lo ejecuto en un QNAP por SMB ACLs y por la conveniencia de tener los backups en el mismo NAS donde ya orquesto todo lo dem\u00e1s. El repo incluye un <code>docker-compose.qnap.yml<\/code> espec\u00edfico, con paths <code>\/share\/<\/code> y un par de detalles que aprend\u00ed por las malas:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Container Station tiene una directiva <code>build:<\/code> que construye la imagen autom\u00e1ticamente desde el <code>Dockerfile<\/code> en una shared folder \u2014 <strong>no hace falta SSH<\/strong>. Solo pegar el YAML en <em>Create \u2192 Application<\/em>.<\/li>\n\n\n\n<li>Los <code>env_file:<\/code> con un <code>.env<\/code> adyacente <strong>no funcionan<\/strong> en Container Station porque CS copia el compose a su directorio interno y no arrastra el <code>.env<\/code> sibling. Hay que usar ruta absoluta\u2026 o aceptar que el PAT vive en el YAML con permisos SMB restringidos. Esto \u00faltimo es lo que el README documenta con honestidad.<\/li>\n\n\n\n<li>El contenedor corre como <code>root<\/code> dentro de Docker \u2014 por dise\u00f1o \u2014 para no pelearse con permisos UID\/GID de QNAP. El control de acceso vive en los ACLs de SMB, no dentro del contenedor.<\/li>\n<\/ul>\n\n\n\n<h2 class=\"wp-block-heading\">Primer run, en producci\u00f3n<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">En mi caso, 5 repos + 6 pipelines de 3 proyectos completaron el primer backup en <strong>28 segundos<\/strong>, con 0 errores. A partir de la segunda ejecuci\u00f3n los repos ya respaldados pasan a modo incremental.<\/p>\n\n\n<div class=\"wp-block-syntaxhighlighter-code \"><pre class=\"brush: plain; title: ; notranslate\" title=\"\">\n&#x5B;INFO] backup run started -&gt; \/backup\/2026-05-21T100033\n&#x5B;INFO] git: Project1\/repo-a backed up (full clone, 4.4 MB, 2.8s)\n&#x5B;INFO] pipelines: Project1 build definitions saved (6)\n&#x5B;INFO] git: Project2\/repo-b backed up (full clone, 145 KB, 1.2s)\n&#x5B;INFO] backup run finished in 28.0s \u2014 repos=5 pipelines=6 wikis=0 errors=0\n&#x5B;INFO] scheduler started \u2014 next run at 2026-05-22 02:00:00+02:00\n<\/pre><\/div>\n\n\n<h2 class=\"wp-block-heading\">Lo que NO hace (v1.0)<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Por honestidad, una lista de cosas que est\u00e1n fuera de alcance en esta primera versi\u00f3n:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Work items, artefactos y test plans.<\/li>\n\n\n\n<li>Restore\/import automatizado (la restauraci\u00f3n hoy es manual con <code>git clone<\/code> desde el mirror \u2014 funciona perfectamente, simplemente no hay un comando \u00abrestore\u00bb).<\/li>\n\n\n\n<li>Multi-organizaci\u00f3n (un contenedor = una org; si tienes varias, levantas varios contenedores).<\/li>\n\n\n\n<li>Autenticaci\u00f3n que no sea PAT.<\/li>\n\n\n\n<li>Repositorios TFVC (solo Git).<\/li>\n<\/ul>\n\n\n\n<h2 class=\"wp-block-heading\">Documentaci\u00f3n biling\u00fce<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">El repo viene con README en <a href=\"https:\/\/github.com\/mainmind83\/ado-backup\/blob\/main\/README.md\">ingl\u00e9s<\/a> y <a href=\"https:\/\/github.com\/mainmind83\/ado-backup\/blob\/main\/README.es.md\">espa\u00f1ol<\/a>. Ambos cubren PAT scopes, configuraci\u00f3n, despliegue en QNAP, formato de salida y restauraci\u00f3n.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Contribuir<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Issues y pull requests son bienvenidos. Hay 20 tests pytest cubriendo config, cliente HTTP de ADO, runner, y cada uno de los backupers (git, pipelines, wikis). Si te animas, mant\u00e9n el verde antes de abrir PR.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<p class=\"has-small-font-size wp-block-paragraph\"><em>Licencia MIT. C\u00f3digo: <a href=\"https:\/\/github.com\/mainmind83\/ado-backup\">github.com\/mainmind83\/ado-backup<\/a><\/em><\/p>\n","protected":false},"excerpt":{"rendered":"<p>Hace unos meses me empez\u00f3 a preocupar algo evidente y sin embargo invisible: que sucede si tu c\u00f3digo vive solo en Azure DevOps. Repositorios, pipelines, wikis. Si ma\u00f1ana Microsoft tuviera un mal d\u00eda \u2014 o nuestra cuenta uno peor \u2014 perder\u00edamos a\u00f1os de trabajo. Y aunque Microsoft tiene SLAs estupendos, \u00abtener una copia de tu [&hellip;]<\/p>\n","protected":false},"author":1,"featured_media":2591,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[15],"tags":[1047,559,1049,1069,1070,890,967],"class_list":["post-2589","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-visual-studio","tag-azure-devops","tag-backup","tag-devops","tag-docker","tag-git","tag-open-source","tag-qnap"],"_links":{"self":[{"href":"https:\/\/www.mainmind.com\/blog\/wp-json\/wp\/v2\/posts\/2589","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/www.mainmind.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/www.mainmind.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/www.mainmind.com\/blog\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/www.mainmind.com\/blog\/wp-json\/wp\/v2\/comments?post=2589"}],"version-history":[{"count":2,"href":"https:\/\/www.mainmind.com\/blog\/wp-json\/wp\/v2\/posts\/2589\/revisions"}],"predecessor-version":[{"id":2592,"href":"https:\/\/www.mainmind.com\/blog\/wp-json\/wp\/v2\/posts\/2589\/revisions\/2592"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/www.mainmind.com\/blog\/wp-json\/wp\/v2\/media\/2591"}],"wp:attachment":[{"href":"https:\/\/www.mainmind.com\/blog\/wp-json\/wp\/v2\/media?parent=2589"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/www.mainmind.com\/blog\/wp-json\/wp\/v2\/categories?post=2589"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/www.mainmind.com\/blog\/wp-json\/wp\/v2\/tags?post=2589"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}