[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"blog-/blog/tutorial/cicd-gitlab-guia-practica":3,"prev-/blog/tutorial/cicd-gitlab-guia-practica":2552,"next-/blog/tutorial/cicd-gitlab-guia-practica":2555,"related-/blog/tutorial/cicd-gitlab-guia-practica":2558},{"id":4,"title":5,"author":6,"authorUrl":7,"body":8,"category":2519,"cta":2520,"date":2523,"dateModified":2523,"description":2524,"draft":2525,"extension":2526,"faq":2527,"featured":2525,"image":2540,"imageAlt":2541,"meta":2542,"navigation":374,"path":2543,"readingTime":384,"seo":2544,"stem":2545,"tags":2546,"__hash__":2551},"blog/blog/tutorial/cicd-gitlab-guia-practica.md","Desplegar aplicaciones con CI/CD en GitLab — guía práctica","Syswork México","/nosotros",{"type":9,"value":10,"toc":2499},"minimark",[11,20,33,38,48,77,81,88,92,95,154,157,171,174,229,232,246,279,283,293,486,489,496,499,1424,1429,1436,1450,1458,1466,1474,1478,1485,1576,1586,1588,1592,1595,1724,1727,1753,1760,2144,2148,2151,2199,2206,2212,2216,2221,2228,2230,2234,2238,2241,2300,2304,2307,2391,2395,2402,2405,2425,2428,2432,2435,2486,2495],[12,13,14,15,19],"p",{},"Desplegar manualmente es el proceso más propenso a errores en el desarrollo de software. Alguien se conecta por SSH al servidor, hace un ",[16,17,18],"code",{},"git pull",", reinicia servicios, cruza los dedos y espera que todo funcione. Cuando no funciona, nadie sabe qué cambió ni cómo volver atrás. CI/CD elimina ese proceso completo: tú haces push a git, el pipeline se encarga del resto.",[12,21,22,23,28,29,32],{},"En esta guía vas a configurar un pipeline de CI/CD en GitLab que compila tu aplicación en un contenedor ",[24,25,27],"a",{"href":26},"/tecnologias/docker","Docker",", ejecuta pruebas, construye la imagen de producción y la despliega en tu servidor — todo automáticamente con cada push a la rama ",[16,30,31],{},"main",".",[34,35,37],"h2",{"id":36},"arquitectura-del-pipeline","Arquitectura del pipeline",[39,40,45],"pre",{"className":41,"code":43,"language":44},[42],"language-text","┌──────┐     ┌──────────────────────────────────────┐     ┌──────────┐\n│ Push │────▶│          GitLab CI Pipeline           │────▶│ Servidor │\n│ main │     │                                        │     │producción│\n└──────┘     │ ┌──────┐ ┌──────┐ ┌───────┐ ┌──────┐ │     └──────────┘\n             │ │Build │→│ Test │→│ Push  │→│Deploy│ │\n             │ │image │ │      │ │registry│ │      │ │\n             │ └──────┘ └──────┘ └───────┘ └──────┘ │\n             └──────────────────────────────────────┘\n","text",[16,46,43],{"__ignoreMap":47},"",[49,50,51,59,65,71],"ol",{},[52,53,54,58],"li",{},[55,56,57],"strong",{},"Build"," — Construye la imagen Docker de tu aplicación",[52,60,61,64],{},[55,62,63],{},"Test"," — Ejecuta linter y pruebas dentro del contenedor",[52,66,67,70],{},[55,68,69],{},"Push"," — Sube la imagen al registry (GitLab Container Registry)",[52,72,73,76],{},[55,74,75],{},"Deploy"," — Conecta al servidor y actualiza los contenedores",[34,78,80],{"id":79},"requisitos-previos","Requisitos previos",[12,82,83,84,87],{},"Un proyecto en GitLab con un ",[16,85,86],{},"Dockerfile"," funcional, un servidor de producción con Docker instalado y acceso SSH desde el runner de CI.",[34,89,91],{"id":90},"paso-1-configurar-el-gitlab-runner","Paso 1: Configurar el GitLab Runner",[12,93,94],{},"El runner es la máquina que ejecuta los jobs del pipeline. Puedes usar los runners compartidos de GitLab.com o instalar uno propio en tu servidor:",[39,96,100],{"className":97,"code":98,"language":99,"meta":47,"style":47},"language-bash shiki shiki-themes material-theme-lighter github-light github-dark","# En tu servidor (o en un servidor dedicado a CI)\ncurl -L https://packages.gitlab.com/install/repositories/runner/gitlab-runner/script.deb.sh | sudo bash\nsudo apt install gitlab-runner -y\n","bash",[16,101,102,111,136],{"__ignoreMap":47},[103,104,107],"span",{"class":105,"line":106},"line",1,[103,108,110],{"class":109},"sutJx","# En tu servidor (o en un servidor dedicado a CI)\n",[103,112,114,118,122,126,130,133],{"class":105,"line":113},2,[103,115,117],{"class":116},"sbgvK","curl",[103,119,121],{"class":120},"stzsN"," -L",[103,123,125],{"class":124},"s_sjI"," https://packages.gitlab.com/install/repositories/runner/gitlab-runner/script.deb.sh",[103,127,129],{"class":128},"smGrS"," |",[103,131,132],{"class":116}," sudo",[103,134,135],{"class":124}," bash\n",[103,137,139,142,145,148,151],{"class":105,"line":138},3,[103,140,141],{"class":116},"sudo",[103,143,144],{"class":124}," apt",[103,146,147],{"class":124}," install",[103,149,150],{"class":124}," gitlab-runner",[103,152,153],{"class":120}," -y\n",[12,155,156],{},"Registra el runner con tu proyecto:",[39,158,160],{"className":97,"code":159,"language":99,"meta":47,"style":47},"sudo gitlab-runner register\n",[16,161,162],{"__ignoreMap":47},[103,163,164,166,168],{"class":105,"line":106},[103,165,141],{"class":116},[103,167,150],{"class":124},[103,169,170],{"class":124}," register\n",[12,172,173],{},"Te pedirá:",[175,176,177,187,197,205,213,221],"ul",{},[52,178,179,182,183,186],{},[55,180,181],{},"GitLab instance URL:"," ",[16,184,185],{},"https://gitlab.com"," (o tu instancia propia)",[52,188,189,192,193,196],{},[55,190,191],{},"Registration token:"," lo encuentras en ",[55,194,195],{},"Settings → CI/CD → Runners"," de tu proyecto",[52,198,199,182,202],{},[55,200,201],{},"Description:",[16,203,204],{},"runner-produccion",[52,206,207,182,210],{},[55,208,209],{},"Tags:",[16,211,212],{},"docker,deploy",[52,214,215,182,218],{},[55,216,217],{},"Executor:",[16,219,220],{},"docker",[52,222,223,182,226],{},[55,224,225],{},"Default Docker image:",[16,227,228],{},"docker:24",[12,230,231],{},"Verifica que el runner está activo:",[39,233,235],{"className":97,"code":234,"language":99,"meta":47,"style":47},"sudo gitlab-runner status\n",[16,236,237],{"__ignoreMap":47},[103,238,239,241,243],{"class":105,"line":106},[103,240,141],{"class":116},[103,242,150],{"class":124},[103,244,245],{"class":124}," status\n",[247,248,251,262,273],"alert",{"title":249,"type":250},"Docker-in-Docker (DinD)","info",[12,252,253,254,257,258,261],{},"Para que el runner pueda construir imágenes Docker, necesita acceso al daemon de Docker. La forma más simple es montar el socket de Docker. Edita ",[16,255,256],{},"/etc/gitlab-runner/config.toml"," y agrega en la sección ",[16,259,260],{},"[runners.docker]",":",[39,263,267],{"className":264,"code":265,"language":266,"meta":47,"style":47},"language-toml shiki shiki-themes material-theme-lighter github-light github-dark","volumes = [\"/var/run/docker.sock:/var/run/docker.sock\", \"/cache\"]\n","toml",[16,268,269],{"__ignoreMap":47},[103,270,271],{"class":105,"line":106},[103,272,265],{},[12,274,275,276],{},"Reinicia: ",[16,277,278],{},"sudo gitlab-runner restart",[34,280,282],{"id":281},"paso-2-preparar-el-dockerfile","Paso 2: Preparar el Dockerfile",[12,284,285,286,288,289,261],{},"Tu aplicación necesita un ",[16,287,86],{}," optimizado para producción. Ejemplo para una API con ",[24,290,292],{"href":291},"/tecnologias/nodejs","Node.js",[39,294,298],{"className":295,"code":296,"language":297,"meta":47,"style":47},"language-dockerfile shiki shiki-themes material-theme-lighter github-light github-dark","# Dockerfile\n# --- Etapa 1: Build ---\nFROM node:20-alpine AS builder\nWORKDIR /app\nCOPY package*.json ./\nRUN npm ci --only=production\nCOPY . .\nRUN npm run build\n\n# --- Etapa 2: Producción ---\nFROM node:20-alpine\nWORKDIR /app\nRUN addgroup -S app && adduser -S app -G app\nCOPY --from=builder /app/dist ./dist\nCOPY --from=builder /app/node_modules ./node_modules\nCOPY --from=builder /app/package.json ./\nUSER app\nEXPOSE 3000\nHEALTHCHECK --interval=30s --timeout=5s --retries=3 \\\n  CMD wget -qO- http://localhost:3000/health || exit 1\nCMD [\"node\", \"dist/main.js\"]\n","dockerfile",[16,299,300,305,310,326,335,344,353,361,369,376,382,390,397,405,413,421,429,438,447,456,465],{"__ignoreMap":47},[103,301,302],{"class":105,"line":106},[103,303,304],{"class":109},"# Dockerfile\n",[103,306,307],{"class":105,"line":113},[103,308,309],{"class":109},"# --- Etapa 1: Build ---\n",[103,311,312,316,320,323],{"class":105,"line":138},[103,313,315],{"class":314},"sw1J6","FROM",[103,317,319],{"class":318},"su5hD"," node:20-alpine ",[103,321,322],{"class":314},"AS",[103,324,325],{"class":318}," builder\n",[103,327,329,332],{"class":105,"line":328},4,[103,330,331],{"class":314},"WORKDIR",[103,333,334],{"class":318}," /app\n",[103,336,338,341],{"class":105,"line":337},5,[103,339,340],{"class":314},"COPY",[103,342,343],{"class":318}," package*.json ./\n",[103,345,347,350],{"class":105,"line":346},6,[103,348,349],{"class":314},"RUN",[103,351,352],{"class":318}," npm ci --only=production\n",[103,354,356,358],{"class":105,"line":355},7,[103,357,340],{"class":314},[103,359,360],{"class":318}," . .\n",[103,362,364,366],{"class":105,"line":363},8,[103,365,349],{"class":314},[103,367,368],{"class":318}," npm run build\n",[103,370,372],{"class":105,"line":371},9,[103,373,375],{"emptyLinePlaceholder":374},true,"\n",[103,377,379],{"class":105,"line":378},10,[103,380,381],{"class":109},"# --- Etapa 2: Producción ---\n",[103,383,385,387],{"class":105,"line":384},11,[103,386,315],{"class":314},[103,388,389],{"class":318}," node:20-alpine\n",[103,391,393,395],{"class":105,"line":392},12,[103,394,331],{"class":314},[103,396,334],{"class":318},[103,398,400,402],{"class":105,"line":399},13,[103,401,349],{"class":314},[103,403,404],{"class":318}," addgroup -S app && adduser -S app -G app\n",[103,406,408,410],{"class":105,"line":407},14,[103,409,340],{"class":314},[103,411,412],{"class":318}," --from=builder /app/dist ./dist\n",[103,414,416,418],{"class":105,"line":415},15,[103,417,340],{"class":314},[103,419,420],{"class":318}," --from=builder /app/node_modules ./node_modules\n",[103,422,424,426],{"class":105,"line":423},16,[103,425,340],{"class":314},[103,427,428],{"class":318}," --from=builder /app/package.json ./\n",[103,430,432,435],{"class":105,"line":431},17,[103,433,434],{"class":314},"USER",[103,436,437],{"class":318}," app\n",[103,439,441,444],{"class":105,"line":440},18,[103,442,443],{"class":314},"EXPOSE",[103,445,446],{"class":318}," 3000\n",[103,448,450,453],{"class":105,"line":449},19,[103,451,452],{"class":314},"HEALTHCHECK",[103,454,455],{"class":318}," --interval=30s --timeout=5s --retries=3 \\\n",[103,457,459,462],{"class":105,"line":458},20,[103,460,461],{"class":314},"  CMD",[103,463,464],{"class":318}," wget -qO- http://localhost:3000/health || exit 1\n",[103,466,468,471,474,477,480,483],{"class":105,"line":467},21,[103,469,470],{"class":314},"CMD",[103,472,473],{"class":318}," [",[103,475,476],{"class":124},"\"node\"",[103,478,479],{"class":318},", ",[103,481,482],{"class":124},"\"dist/main.js\"",[103,484,485],{"class":318},"]\n",[487,488],"ad-banner",{},[34,490,492,493],{"id":491},"paso-3-el-archivo-gitlab-ciyml","Paso 3: El archivo ",[16,494,495],{},".gitlab-ci.yml",[12,497,498],{},"Este es el corazón del pipeline. Crea el archivo en la raíz de tu proyecto:",[39,500,504],{"className":501,"code":502,"language":503,"meta":47,"style":47},"language-yaml shiki shiki-themes material-theme-lighter github-light github-dark","# .gitlab-ci.yml\nstages:\n  - build\n  - test\n  - push\n  - deploy\n\nvariables:\n  IMAGE_NAME: $CI_REGISTRY_IMAGE\n  IMAGE_TAG: $CI_COMMIT_SHORT_SHA\n\n# ==============================\n# BUILD — Construir imagen Docker\n# ==============================\nbuild:\n  stage: build\n  image: docker:24\n  tags: [docker]\n  script:\n    - docker build -t $IMAGE_NAME:$IMAGE_TAG -t $IMAGE_NAME:latest .\n    - docker save $IMAGE_NAME:$IMAGE_TAG > image.tar\n  artifacts:\n    paths:\n      - image.tar\n    expire_in: 1 hour\n  only:\n    - main\n    - merge_requests\n\n# ==============================\n# TEST — Ejecutar pruebas\n# ==============================\ntest:lint:\n  stage: test\n  image: node:20-alpine\n  tags: [docker]\n  script:\n    - npm ci\n    - npm run lint\n  only:\n    - main\n    - merge_requests\n\ntest:unit:\n  stage: test\n  image: node:20-alpine\n  tags: [docker]\n  services:\n    - postgres:16-alpine\n  variables:\n    POSTGRES_DB: test\n    POSTGRES_USER: test\n    POSTGRES_PASSWORD: test\n    DATABASE_URL: postgresql://test:test@postgres:5432/test\n  script:\n    - npm ci\n    - npm run test\n  only:\n    - main\n    - merge_requests\n\n# ==============================\n# PUSH — Subir imagen al registry\n# ==============================\npush:\n  stage: push\n  image: docker:24\n  tags: [docker]\n  before_script:\n    - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY\n  script:\n    - docker load \u003C image.tar\n    - docker push $IMAGE_NAME:$IMAGE_TAG\n    - docker push $IMAGE_NAME:latest\n  only:\n    - main\n\n# ==============================\n# DEPLOY — Desplegar en producción\n# ==============================\ndeploy:production:\n  stage: deploy\n  image: alpine:latest\n  tags: [docker]\n  before_script:\n    - apk add --no-cache openssh-client\n    - eval $(ssh-agent -s)\n    - echo \"$SSH_PRIVATE_KEY\" | ssh-add -\n    - mkdir -p ~/.ssh\n    - echo \"$SSH_KNOWN_HOSTS\" >> ~/.ssh/known_hosts\n  script:\n    - |\n      ssh $SSH_USER@$SSH_HOST \u003C\u003C 'DEPLOY'\n        cd /opt/mi-app\n        \n        # Login al registry\n        docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY\n        \n        # Pull nueva imagen\n        docker compose pull app\n        \n        # Deploy con zero-downtime\n        docker compose up -d --no-deps app\n        \n        # Verificar health\n        sleep 10\n        if ! docker compose exec -T app wget -qO- http://localhost:3000/health; then\n          echo \"HEALTH CHECK FAILED — Rollback!\"\n          docker compose rollback app 2>/dev/null || docker compose up -d\n          exit 1\n        fi\n        \n        # Limpiar imágenes antiguas\n        docker image prune -f\n        \n        echo \"Deploy completado: $IMAGE_TAG\"\n      DEPLOY\n  environment:\n    name: production\n    url: https://app.tuempresa.com\n  only:\n    - main\n  when: manual  # Requiere click manual para desplegar\n","yaml",[16,505,506,511,521,529,536,543,550,554,561,571,581,585,590,595,599,606,615,625,638,645,653,660,668,676,685,696,704,712,720,725,730,736,741,749,758,767,780,787,795,803,810,817,824,829,837,846,855,868,876,884,892,902,912,922,933,940,947,955,962,969,976,981,986,992,997,1005,1014,1023,1036,1044,1052,1059,1067,1075,1083,1090,1097,1102,1107,1113,1118,1126,1135,1145,1158,1165,1173,1181,1189,1197,1205,1212,1221,1227,1233,1239,1245,1251,1256,1262,1268,1273,1279,1285,1290,1296,1302,1308,1314,1320,1326,1332,1337,1343,1349,1354,1360,1366,1374,1385,1396,1403,1410],{"__ignoreMap":47},[103,507,508],{"class":105,"line":106},[103,509,510],{"class":109},"# .gitlab-ci.yml\n",[103,512,513,517],{"class":105,"line":113},[103,514,516],{"class":515},"sQzsp","stages",[103,518,520],{"class":519},"sP7_E",":\n",[103,522,523,526],{"class":105,"line":138},[103,524,525],{"class":519},"  -",[103,527,528],{"class":124}," build\n",[103,530,531,533],{"class":105,"line":328},[103,532,525],{"class":519},[103,534,535],{"class":124}," test\n",[103,537,538,540],{"class":105,"line":337},[103,539,525],{"class":519},[103,541,542],{"class":124}," push\n",[103,544,545,547],{"class":105,"line":346},[103,546,525],{"class":519},[103,548,549],{"class":124}," deploy\n",[103,551,552],{"class":105,"line":355},[103,553,375],{"emptyLinePlaceholder":374},[103,555,556,559],{"class":105,"line":363},[103,557,558],{"class":515},"variables",[103,560,520],{"class":519},[103,562,563,566,568],{"class":105,"line":371},[103,564,565],{"class":515},"  IMAGE_NAME",[103,567,261],{"class":519},[103,569,570],{"class":124}," $CI_REGISTRY_IMAGE\n",[103,572,573,576,578],{"class":105,"line":378},[103,574,575],{"class":515},"  IMAGE_TAG",[103,577,261],{"class":519},[103,579,580],{"class":124}," $CI_COMMIT_SHORT_SHA\n",[103,582,583],{"class":105,"line":384},[103,584,375],{"emptyLinePlaceholder":374},[103,586,587],{"class":105,"line":392},[103,588,589],{"class":109},"# ==============================\n",[103,591,592],{"class":105,"line":399},[103,593,594],{"class":109},"# BUILD — Construir imagen Docker\n",[103,596,597],{"class":105,"line":407},[103,598,589],{"class":109},[103,600,601,604],{"class":105,"line":415},[103,602,603],{"class":515},"build",[103,605,520],{"class":519},[103,607,608,611,613],{"class":105,"line":423},[103,609,610],{"class":515},"  stage",[103,612,261],{"class":519},[103,614,528],{"class":124},[103,616,617,620,622],{"class":105,"line":431},[103,618,619],{"class":515},"  image",[103,621,261],{"class":519},[103,623,624],{"class":124}," docker:24\n",[103,626,627,630,632,634,636],{"class":105,"line":440},[103,628,629],{"class":515},"  tags",[103,631,261],{"class":519},[103,633,473],{"class":519},[103,635,220],{"class":124},[103,637,485],{"class":519},[103,639,640,643],{"class":105,"line":449},[103,641,642],{"class":515},"  script",[103,644,520],{"class":519},[103,646,647,650],{"class":105,"line":458},[103,648,649],{"class":519},"    -",[103,651,652],{"class":124}," docker build -t $IMAGE_NAME:$IMAGE_TAG -t $IMAGE_NAME:latest .\n",[103,654,655,657],{"class":105,"line":467},[103,656,649],{"class":519},[103,658,659],{"class":124}," docker save $IMAGE_NAME:$IMAGE_TAG > image.tar\n",[103,661,663,666],{"class":105,"line":662},22,[103,664,665],{"class":515},"  artifacts",[103,667,520],{"class":519},[103,669,671,674],{"class":105,"line":670},23,[103,672,673],{"class":515},"    paths",[103,675,520],{"class":519},[103,677,679,682],{"class":105,"line":678},24,[103,680,681],{"class":519},"      -",[103,683,684],{"class":124}," image.tar\n",[103,686,688,691,693],{"class":105,"line":687},25,[103,689,690],{"class":515},"    expire_in",[103,692,261],{"class":519},[103,694,695],{"class":124}," 1 hour\n",[103,697,699,702],{"class":105,"line":698},26,[103,700,701],{"class":515},"  only",[103,703,520],{"class":519},[103,705,707,709],{"class":105,"line":706},27,[103,708,649],{"class":519},[103,710,711],{"class":124}," main\n",[103,713,715,717],{"class":105,"line":714},28,[103,716,649],{"class":519},[103,718,719],{"class":124}," merge_requests\n",[103,721,723],{"class":105,"line":722},29,[103,724,375],{"emptyLinePlaceholder":374},[103,726,728],{"class":105,"line":727},30,[103,729,589],{"class":109},[103,731,733],{"class":105,"line":732},31,[103,734,735],{"class":109},"# TEST — Ejecutar pruebas\n",[103,737,739],{"class":105,"line":738},32,[103,740,589],{"class":109},[103,742,744,747],{"class":105,"line":743},33,[103,745,746],{"class":515},"test:lint",[103,748,520],{"class":519},[103,750,752,754,756],{"class":105,"line":751},34,[103,753,610],{"class":515},[103,755,261],{"class":519},[103,757,535],{"class":124},[103,759,761,763,765],{"class":105,"line":760},35,[103,762,619],{"class":515},[103,764,261],{"class":519},[103,766,389],{"class":124},[103,768,770,772,774,776,778],{"class":105,"line":769},36,[103,771,629],{"class":515},[103,773,261],{"class":519},[103,775,473],{"class":519},[103,777,220],{"class":124},[103,779,485],{"class":519},[103,781,783,785],{"class":105,"line":782},37,[103,784,642],{"class":515},[103,786,520],{"class":519},[103,788,790,792],{"class":105,"line":789},38,[103,791,649],{"class":519},[103,793,794],{"class":124}," npm ci\n",[103,796,798,800],{"class":105,"line":797},39,[103,799,649],{"class":519},[103,801,802],{"class":124}," npm run lint\n",[103,804,806,808],{"class":105,"line":805},40,[103,807,701],{"class":515},[103,809,520],{"class":519},[103,811,813,815],{"class":105,"line":812},41,[103,814,649],{"class":519},[103,816,711],{"class":124},[103,818,820,822],{"class":105,"line":819},42,[103,821,649],{"class":519},[103,823,719],{"class":124},[103,825,827],{"class":105,"line":826},43,[103,828,375],{"emptyLinePlaceholder":374},[103,830,832,835],{"class":105,"line":831},44,[103,833,834],{"class":515},"test:unit",[103,836,520],{"class":519},[103,838,840,842,844],{"class":105,"line":839},45,[103,841,610],{"class":515},[103,843,261],{"class":519},[103,845,535],{"class":124},[103,847,849,851,853],{"class":105,"line":848},46,[103,850,619],{"class":515},[103,852,261],{"class":519},[103,854,389],{"class":124},[103,856,858,860,862,864,866],{"class":105,"line":857},47,[103,859,629],{"class":515},[103,861,261],{"class":519},[103,863,473],{"class":519},[103,865,220],{"class":124},[103,867,485],{"class":519},[103,869,871,874],{"class":105,"line":870},48,[103,872,873],{"class":515},"  services",[103,875,520],{"class":519},[103,877,879,881],{"class":105,"line":878},49,[103,880,649],{"class":519},[103,882,883],{"class":124}," postgres:16-alpine\n",[103,885,887,890],{"class":105,"line":886},50,[103,888,889],{"class":515},"  variables",[103,891,520],{"class":519},[103,893,895,898,900],{"class":105,"line":894},51,[103,896,897],{"class":515},"    POSTGRES_DB",[103,899,261],{"class":519},[103,901,535],{"class":124},[103,903,905,908,910],{"class":105,"line":904},52,[103,906,907],{"class":515},"    POSTGRES_USER",[103,909,261],{"class":519},[103,911,535],{"class":124},[103,913,915,918,920],{"class":105,"line":914},53,[103,916,917],{"class":515},"    POSTGRES_PASSWORD",[103,919,261],{"class":519},[103,921,535],{"class":124},[103,923,925,928,930],{"class":105,"line":924},54,[103,926,927],{"class":515},"    DATABASE_URL",[103,929,261],{"class":519},[103,931,932],{"class":124}," postgresql://test:test@postgres:5432/test\n",[103,934,936,938],{"class":105,"line":935},55,[103,937,642],{"class":515},[103,939,520],{"class":519},[103,941,943,945],{"class":105,"line":942},56,[103,944,649],{"class":519},[103,946,794],{"class":124},[103,948,950,952],{"class":105,"line":949},57,[103,951,649],{"class":519},[103,953,954],{"class":124}," npm run test\n",[103,956,958,960],{"class":105,"line":957},58,[103,959,701],{"class":515},[103,961,520],{"class":519},[103,963,965,967],{"class":105,"line":964},59,[103,966,649],{"class":519},[103,968,711],{"class":124},[103,970,972,974],{"class":105,"line":971},60,[103,973,649],{"class":519},[103,975,719],{"class":124},[103,977,979],{"class":105,"line":978},61,[103,980,375],{"emptyLinePlaceholder":374},[103,982,984],{"class":105,"line":983},62,[103,985,589],{"class":109},[103,987,989],{"class":105,"line":988},63,[103,990,991],{"class":109},"# PUSH — Subir imagen al registry\n",[103,993,995],{"class":105,"line":994},64,[103,996,589],{"class":109},[103,998,1000,1003],{"class":105,"line":999},65,[103,1001,1002],{"class":515},"push",[103,1004,520],{"class":519},[103,1006,1008,1010,1012],{"class":105,"line":1007},66,[103,1009,610],{"class":515},[103,1011,261],{"class":519},[103,1013,542],{"class":124},[103,1015,1017,1019,1021],{"class":105,"line":1016},67,[103,1018,619],{"class":515},[103,1020,261],{"class":519},[103,1022,624],{"class":124},[103,1024,1026,1028,1030,1032,1034],{"class":105,"line":1025},68,[103,1027,629],{"class":515},[103,1029,261],{"class":519},[103,1031,473],{"class":519},[103,1033,220],{"class":124},[103,1035,485],{"class":519},[103,1037,1039,1042],{"class":105,"line":1038},69,[103,1040,1041],{"class":515},"  before_script",[103,1043,520],{"class":519},[103,1045,1047,1049],{"class":105,"line":1046},70,[103,1048,649],{"class":519},[103,1050,1051],{"class":124}," docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY\n",[103,1053,1055,1057],{"class":105,"line":1054},71,[103,1056,642],{"class":515},[103,1058,520],{"class":519},[103,1060,1062,1064],{"class":105,"line":1061},72,[103,1063,649],{"class":519},[103,1065,1066],{"class":124}," docker load \u003C image.tar\n",[103,1068,1070,1072],{"class":105,"line":1069},73,[103,1071,649],{"class":519},[103,1073,1074],{"class":124}," docker push $IMAGE_NAME:$IMAGE_TAG\n",[103,1076,1078,1080],{"class":105,"line":1077},74,[103,1079,649],{"class":519},[103,1081,1082],{"class":124}," docker push $IMAGE_NAME:latest\n",[103,1084,1086,1088],{"class":105,"line":1085},75,[103,1087,701],{"class":515},[103,1089,520],{"class":519},[103,1091,1093,1095],{"class":105,"line":1092},76,[103,1094,649],{"class":519},[103,1096,711],{"class":124},[103,1098,1100],{"class":105,"line":1099},77,[103,1101,375],{"emptyLinePlaceholder":374},[103,1103,1105],{"class":105,"line":1104},78,[103,1106,589],{"class":109},[103,1108,1110],{"class":105,"line":1109},79,[103,1111,1112],{"class":109},"# DEPLOY — Desplegar en producción\n",[103,1114,1116],{"class":105,"line":1115},80,[103,1117,589],{"class":109},[103,1119,1121,1124],{"class":105,"line":1120},81,[103,1122,1123],{"class":515},"deploy:production",[103,1125,520],{"class":519},[103,1127,1129,1131,1133],{"class":105,"line":1128},82,[103,1130,610],{"class":515},[103,1132,261],{"class":519},[103,1134,549],{"class":124},[103,1136,1138,1140,1142],{"class":105,"line":1137},83,[103,1139,619],{"class":515},[103,1141,261],{"class":519},[103,1143,1144],{"class":124}," alpine:latest\n",[103,1146,1148,1150,1152,1154,1156],{"class":105,"line":1147},84,[103,1149,629],{"class":515},[103,1151,261],{"class":519},[103,1153,473],{"class":519},[103,1155,220],{"class":124},[103,1157,485],{"class":519},[103,1159,1161,1163],{"class":105,"line":1160},85,[103,1162,1041],{"class":515},[103,1164,520],{"class":519},[103,1166,1168,1170],{"class":105,"line":1167},86,[103,1169,649],{"class":519},[103,1171,1172],{"class":124}," apk add --no-cache openssh-client\n",[103,1174,1176,1178],{"class":105,"line":1175},87,[103,1177,649],{"class":519},[103,1179,1180],{"class":124}," eval $(ssh-agent -s)\n",[103,1182,1184,1186],{"class":105,"line":1183},88,[103,1185,649],{"class":519},[103,1187,1188],{"class":124}," echo \"$SSH_PRIVATE_KEY\" | ssh-add -\n",[103,1190,1192,1194],{"class":105,"line":1191},89,[103,1193,649],{"class":519},[103,1195,1196],{"class":124}," mkdir -p ~/.ssh\n",[103,1198,1200,1202],{"class":105,"line":1199},90,[103,1201,649],{"class":519},[103,1203,1204],{"class":124}," echo \"$SSH_KNOWN_HOSTS\" >> ~/.ssh/known_hosts\n",[103,1206,1208,1210],{"class":105,"line":1207},91,[103,1209,642],{"class":515},[103,1211,520],{"class":519},[103,1213,1215,1217],{"class":105,"line":1214},92,[103,1216,649],{"class":519},[103,1218,1220],{"class":1219},"sVHd0"," |\n",[103,1222,1224],{"class":105,"line":1223},93,[103,1225,1226],{"class":124},"      ssh $SSH_USER@$SSH_HOST \u003C\u003C 'DEPLOY'\n",[103,1228,1230],{"class":105,"line":1229},94,[103,1231,1232],{"class":124},"        cd /opt/mi-app\n",[103,1234,1236],{"class":105,"line":1235},95,[103,1237,1238],{"class":124},"        \n",[103,1240,1242],{"class":105,"line":1241},96,[103,1243,1244],{"class":124},"        # Login al registry\n",[103,1246,1248],{"class":105,"line":1247},97,[103,1249,1250],{"class":124},"        docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY\n",[103,1252,1254],{"class":105,"line":1253},98,[103,1255,1238],{"class":124},[103,1257,1259],{"class":105,"line":1258},99,[103,1260,1261],{"class":124},"        # Pull nueva imagen\n",[103,1263,1265],{"class":105,"line":1264},100,[103,1266,1267],{"class":124},"        docker compose pull app\n",[103,1269,1271],{"class":105,"line":1270},101,[103,1272,1238],{"class":124},[103,1274,1276],{"class":105,"line":1275},102,[103,1277,1278],{"class":124},"        # Deploy con zero-downtime\n",[103,1280,1282],{"class":105,"line":1281},103,[103,1283,1284],{"class":124},"        docker compose up -d --no-deps app\n",[103,1286,1288],{"class":105,"line":1287},104,[103,1289,1238],{"class":124},[103,1291,1293],{"class":105,"line":1292},105,[103,1294,1295],{"class":124},"        # Verificar health\n",[103,1297,1299],{"class":105,"line":1298},106,[103,1300,1301],{"class":124},"        sleep 10\n",[103,1303,1305],{"class":105,"line":1304},107,[103,1306,1307],{"class":124},"        if ! docker compose exec -T app wget -qO- http://localhost:3000/health; then\n",[103,1309,1311],{"class":105,"line":1310},108,[103,1312,1313],{"class":124},"          echo \"HEALTH CHECK FAILED — Rollback!\"\n",[103,1315,1317],{"class":105,"line":1316},109,[103,1318,1319],{"class":124},"          docker compose rollback app 2>/dev/null || docker compose up -d\n",[103,1321,1323],{"class":105,"line":1322},110,[103,1324,1325],{"class":124},"          exit 1\n",[103,1327,1329],{"class":105,"line":1328},111,[103,1330,1331],{"class":124},"        fi\n",[103,1333,1335],{"class":105,"line":1334},112,[103,1336,1238],{"class":124},[103,1338,1340],{"class":105,"line":1339},113,[103,1341,1342],{"class":124},"        # Limpiar imágenes antiguas\n",[103,1344,1346],{"class":105,"line":1345},114,[103,1347,1348],{"class":124},"        docker image prune -f\n",[103,1350,1352],{"class":105,"line":1351},115,[103,1353,1238],{"class":124},[103,1355,1357],{"class":105,"line":1356},116,[103,1358,1359],{"class":124},"        echo \"Deploy completado: $IMAGE_TAG\"\n",[103,1361,1363],{"class":105,"line":1362},117,[103,1364,1365],{"class":124},"      DEPLOY\n",[103,1367,1369,1372],{"class":105,"line":1368},118,[103,1370,1371],{"class":515},"  environment",[103,1373,520],{"class":519},[103,1375,1377,1380,1382],{"class":105,"line":1376},119,[103,1378,1379],{"class":515},"    name",[103,1381,261],{"class":519},[103,1383,1384],{"class":124}," production\n",[103,1386,1388,1391,1393],{"class":105,"line":1387},120,[103,1389,1390],{"class":515},"    url",[103,1392,261],{"class":519},[103,1394,1395],{"class":124}," https://app.tuempresa.com\n",[103,1397,1399,1401],{"class":105,"line":1398},121,[103,1400,701],{"class":515},[103,1402,520],{"class":519},[103,1404,1406,1408],{"class":105,"line":1405},122,[103,1407,649],{"class":519},[103,1409,711],{"class":124},[103,1411,1413,1416,1418,1421],{"class":105,"line":1412},123,[103,1414,1415],{"class":515},"  when",[103,1417,261],{"class":519},[103,1419,1420],{"class":124}," manual",[103,1422,1423],{"class":109},"  # Requiere click manual para desplegar\n",[1425,1426,1428],"h3",{"id":1427},"conceptos-clave","Conceptos clave",[12,1430,1431,1435],{},[55,1432,1433],{},[16,1434,516],{}," — Define el orden de ejecución. Todos los jobs de un stage se ejecutan en paralelo; el siguiente stage empieza cuando todos terminaron.",[12,1437,1438,1443,1444,1446,1447,1449],{},[55,1439,1440],{},[16,1441,1442],{},"artifacts"," — Archivos que se pasan entre stages. La imagen construida en ",[16,1445,603],{}," se pasa a ",[16,1448,1002],{}," como archivo tar.",[12,1451,1452,1457],{},[55,1453,1454],{},[16,1455,1456],{},"services"," — Contenedores auxiliares que corren junto al job. Aquí PostgreSQL para las pruebas unitarias.",[12,1459,1460,1465],{},[55,1461,1462],{},[16,1463,1464],{},"when: manual"," — El deploy a producción requiere un click manual en la interfaz de GitLab. Esto te da control sobre cuándo sale un cambio a producción.",[12,1467,1468,1473],{},[55,1469,1470],{},[16,1471,1472],{},"environment"," — GitLab registra cada deploy con su commit, fecha y URL — historial completo de qué se desplegó y cuándo.",[34,1475,1477],{"id":1476},"paso-4-variables-de-cicd","Paso 4: Variables de CI/CD",[12,1479,1480,1481,1484],{},"Configura las variables secretas en ",[55,1482,1483],{},"Settings → CI/CD → Variables"," de tu proyecto:",[1486,1487,1488,1507],"table",{},[1489,1490,1491],"thead",{},[1492,1493,1494,1498,1501,1504],"tr",{},[1495,1496,1497],"th",{},"Variable",[1495,1499,1500],{},"Valor",[1495,1502,1503],{},"Protegida",[1495,1505,1506],{},"Enmascarada",[1508,1509,1510,1526,1545,1562],"tbody",{},[1492,1511,1512,1518,1521,1524],{},[1513,1514,1515],"td",{},[16,1516,1517],{},"SSH_PRIVATE_KEY",[1513,1519,1520],{},"Llave privada SSH para conectar al servidor",[1513,1522,1523],{},"✅",[1513,1525,1523],{},[1492,1527,1528,1533,1540,1542],{},[1513,1529,1530],{},[16,1531,1532],{},"SSH_KNOWN_HOSTS",[1513,1534,1535,1536,1539],{},"Fingerprint del servidor (",[16,1537,1538],{},"ssh-keyscan tu-servidor",")",[1513,1541,1523],{},[1513,1543,1544],{},"❌",[1492,1546,1547,1552,1558,1560],{},[1513,1548,1549],{},[16,1550,1551],{},"SSH_USER",[1513,1553,1554,1555,1539],{},"Usuario SSH (",[16,1556,1557],{},"deploy",[1513,1559,1523],{},[1513,1561,1544],{},[1492,1563,1564,1569,1572,1574],{},[1513,1565,1566],{},[16,1567,1568],{},"SSH_HOST",[1513,1570,1571],{},"IP o dominio del servidor",[1513,1573,1523],{},[1513,1575,1544],{},[247,1577,1580],{"title":1578,"type":1579},"Nunca pongas secretos en el .gitlab-ci.yml","warning",[12,1581,1582,1583,1585],{},"Las llaves SSH, contraseñas y tokens van en las variables de CI/CD de GitLab — cifradas y solo disponibles durante la ejecución del pipeline. Marcarlas como \"Protected\" las limita a ramas protegidas (como ",[16,1584,31],{},").",[487,1587],{},[34,1589,1591],{"id":1590},"paso-5-preparar-el-servidor-de-producción","Paso 5: Preparar el servidor de producción",[12,1593,1594],{},"En tu servidor, crea un usuario dedicado para los deploys:",[39,1596,1598],{"className":97,"code":1597,"language":99,"meta":47,"style":47},"# Crear usuario deploy\nsudo adduser --disabled-password deploy\nsudo usermod -aG docker deploy\n\n# Configurar llave SSH\nsudo mkdir -p /home/deploy/.ssh\n# Copia la llave pública que corresponde a SSH_PRIVATE_KEY\necho \"ssh-ed25519 AAAA...tu-llave-publica\" | sudo tee /home/deploy/.ssh/authorized_keys\nsudo chown -R deploy:deploy /home/deploy/.ssh\nsudo chmod 700 /home/deploy/.ssh\nsudo chmod 600 /home/deploy/.ssh/authorized_keys\n",[16,1599,1600,1605,1617,1632,1636,1641,1654,1659,1685,1700,1713],{"__ignoreMap":47},[103,1601,1602],{"class":105,"line":106},[103,1603,1604],{"class":109},"# Crear usuario deploy\n",[103,1606,1607,1609,1612,1615],{"class":105,"line":113},[103,1608,141],{"class":116},[103,1610,1611],{"class":124}," adduser",[103,1613,1614],{"class":120}," --disabled-password",[103,1616,549],{"class":124},[103,1618,1619,1621,1624,1627,1630],{"class":105,"line":138},[103,1620,141],{"class":116},[103,1622,1623],{"class":124}," usermod",[103,1625,1626],{"class":120}," -aG",[103,1628,1629],{"class":124}," docker",[103,1631,549],{"class":124},[103,1633,1634],{"class":105,"line":328},[103,1635,375],{"emptyLinePlaceholder":374},[103,1637,1638],{"class":105,"line":337},[103,1639,1640],{"class":109},"# Configurar llave SSH\n",[103,1642,1643,1645,1648,1651],{"class":105,"line":346},[103,1644,141],{"class":116},[103,1646,1647],{"class":124}," mkdir",[103,1649,1650],{"class":120}," -p",[103,1652,1653],{"class":124}," /home/deploy/.ssh\n",[103,1655,1656],{"class":105,"line":355},[103,1657,1658],{"class":109},"# Copia la llave pública que corresponde a SSH_PRIVATE_KEY\n",[103,1660,1661,1665,1669,1672,1675,1677,1679,1682],{"class":105,"line":363},[103,1662,1664],{"class":1663},"sptTA","echo",[103,1666,1668],{"class":1667},"sjJ54"," \"",[103,1670,1671],{"class":124},"ssh-ed25519 AAAA...tu-llave-publica",[103,1673,1674],{"class":1667},"\"",[103,1676,129],{"class":128},[103,1678,132],{"class":116},[103,1680,1681],{"class":124}," tee",[103,1683,1684],{"class":124}," /home/deploy/.ssh/authorized_keys\n",[103,1686,1687,1689,1692,1695,1698],{"class":105,"line":371},[103,1688,141],{"class":116},[103,1690,1691],{"class":124}," chown",[103,1693,1694],{"class":120}," -R",[103,1696,1697],{"class":124}," deploy:deploy",[103,1699,1653],{"class":124},[103,1701,1702,1704,1707,1711],{"class":105,"line":378},[103,1703,141],{"class":116},[103,1705,1706],{"class":124}," chmod",[103,1708,1710],{"class":1709},"srdBf"," 700",[103,1712,1653],{"class":124},[103,1714,1715,1717,1719,1722],{"class":105,"line":384},[103,1716,141],{"class":116},[103,1718,1706],{"class":124},[103,1720,1721],{"class":1709}," 600",[103,1723,1684],{"class":124},[12,1725,1726],{},"Crea la estructura de la aplicación:",[39,1728,1730],{"className":97,"code":1729,"language":99,"meta":47,"style":47},"sudo mkdir -p /opt/mi-app\nsudo chown deploy:deploy /opt/mi-app\n",[16,1731,1732,1743],{"__ignoreMap":47},[103,1733,1734,1736,1738,1740],{"class":105,"line":106},[103,1735,141],{"class":116},[103,1737,1647],{"class":124},[103,1739,1650],{"class":120},[103,1741,1742],{"class":124}," /opt/mi-app\n",[103,1744,1745,1747,1749,1751],{"class":105,"line":113},[103,1746,141],{"class":116},[103,1748,1691],{"class":124},[103,1750,1697],{"class":124},[103,1752,1742],{"class":124},[12,1754,1755,1756,1759],{},"Crea el ",[16,1757,1758],{},"docker-compose.yml"," de producción en el servidor:",[39,1761,1763],{"className":501,"code":1762,"language":503,"meta":47,"style":47},"# /opt/mi-app/docker-compose.yml\nservices:\n  app:\n    image: registry.gitlab.com/tu-usuario/mi-app:latest\n    restart: unless-stopped\n    environment:\n      DATABASE_URL: postgresql://app:${DB_PASSWORD}@db:5432/miapp\n      NODE_ENV: production\n    depends_on:\n      db:\n        condition: service_healthy\n    networks:\n      - proxy\n      - backend\n    labels:\n      - \"traefik.enable=true\"\n      - \"traefik.http.routers.mi-app.rule=Host(`app.tuempresa.com`)\"\n      - \"traefik.http.routers.mi-app.entrypoints=websecure\"\n      - \"traefik.http.routers.mi-app.tls.certresolver=letsencrypt\"\n      - \"traefik.http.services.mi-app.loadbalancer.server.port=3000\"\n\n  db:\n    image: postgres:16-alpine\n    restart: unless-stopped\n    environment:\n      POSTGRES_DB: miapp\n      POSTGRES_USER: app\n      POSTGRES_PASSWORD: ${DB_PASSWORD}\n    volumes:\n      - pg_data:/var/lib/postgresql/data\n    healthcheck:\n      test: [\"CMD-SHELL\", \"pg_isready -U app -d miapp\"]\n      interval: 10s\n      timeout: 5s\n      retries: 5\n    networks:\n      - backend\n\nnetworks:\n  proxy:\n    external: true\n  backend:\n\nvolumes:\n  pg_data:\n",[16,1764,1765,1770,1776,1783,1793,1803,1810,1820,1829,1836,1843,1853,1860,1867,1874,1881,1893,1904,1915,1926,1937,1941,1948,1956,1964,1970,1980,1989,1999,2006,2013,2020,2048,2058,2068,2078,2084,2090,2094,2101,2108,2119,2126,2130,2137],{"__ignoreMap":47},[103,1766,1767],{"class":105,"line":106},[103,1768,1769],{"class":109},"# /opt/mi-app/docker-compose.yml\n",[103,1771,1772,1774],{"class":105,"line":113},[103,1773,1456],{"class":515},[103,1775,520],{"class":519},[103,1777,1778,1781],{"class":105,"line":138},[103,1779,1780],{"class":515},"  app",[103,1782,520],{"class":519},[103,1784,1785,1788,1790],{"class":105,"line":328},[103,1786,1787],{"class":515},"    image",[103,1789,261],{"class":519},[103,1791,1792],{"class":124}," registry.gitlab.com/tu-usuario/mi-app:latest\n",[103,1794,1795,1798,1800],{"class":105,"line":337},[103,1796,1797],{"class":515},"    restart",[103,1799,261],{"class":519},[103,1801,1802],{"class":124}," unless-stopped\n",[103,1804,1805,1808],{"class":105,"line":346},[103,1806,1807],{"class":515},"    environment",[103,1809,520],{"class":519},[103,1811,1812,1815,1817],{"class":105,"line":355},[103,1813,1814],{"class":515},"      DATABASE_URL",[103,1816,261],{"class":519},[103,1818,1819],{"class":124}," postgresql://app:${DB_PASSWORD}@db:5432/miapp\n",[103,1821,1822,1825,1827],{"class":105,"line":363},[103,1823,1824],{"class":515},"      NODE_ENV",[103,1826,261],{"class":519},[103,1828,1384],{"class":124},[103,1830,1831,1834],{"class":105,"line":371},[103,1832,1833],{"class":515},"    depends_on",[103,1835,520],{"class":519},[103,1837,1838,1841],{"class":105,"line":378},[103,1839,1840],{"class":515},"      db",[103,1842,520],{"class":519},[103,1844,1845,1848,1850],{"class":105,"line":384},[103,1846,1847],{"class":515},"        condition",[103,1849,261],{"class":519},[103,1851,1852],{"class":124}," service_healthy\n",[103,1854,1855,1858],{"class":105,"line":392},[103,1856,1857],{"class":515},"    networks",[103,1859,520],{"class":519},[103,1861,1862,1864],{"class":105,"line":399},[103,1863,681],{"class":519},[103,1865,1866],{"class":124}," proxy\n",[103,1868,1869,1871],{"class":105,"line":407},[103,1870,681],{"class":519},[103,1872,1873],{"class":124}," backend\n",[103,1875,1876,1879],{"class":105,"line":415},[103,1877,1878],{"class":515},"    labels",[103,1880,520],{"class":519},[103,1882,1883,1885,1887,1890],{"class":105,"line":423},[103,1884,681],{"class":519},[103,1886,1668],{"class":1667},[103,1888,1889],{"class":124},"traefik.enable=true",[103,1891,1892],{"class":1667},"\"\n",[103,1894,1895,1897,1899,1902],{"class":105,"line":431},[103,1896,681],{"class":519},[103,1898,1668],{"class":1667},[103,1900,1901],{"class":124},"traefik.http.routers.mi-app.rule=Host(`app.tuempresa.com`)",[103,1903,1892],{"class":1667},[103,1905,1906,1908,1910,1913],{"class":105,"line":440},[103,1907,681],{"class":519},[103,1909,1668],{"class":1667},[103,1911,1912],{"class":124},"traefik.http.routers.mi-app.entrypoints=websecure",[103,1914,1892],{"class":1667},[103,1916,1917,1919,1921,1924],{"class":105,"line":449},[103,1918,681],{"class":519},[103,1920,1668],{"class":1667},[103,1922,1923],{"class":124},"traefik.http.routers.mi-app.tls.certresolver=letsencrypt",[103,1925,1892],{"class":1667},[103,1927,1928,1930,1932,1935],{"class":105,"line":458},[103,1929,681],{"class":519},[103,1931,1668],{"class":1667},[103,1933,1934],{"class":124},"traefik.http.services.mi-app.loadbalancer.server.port=3000",[103,1936,1892],{"class":1667},[103,1938,1939],{"class":105,"line":467},[103,1940,375],{"emptyLinePlaceholder":374},[103,1942,1943,1946],{"class":105,"line":662},[103,1944,1945],{"class":515},"  db",[103,1947,520],{"class":519},[103,1949,1950,1952,1954],{"class":105,"line":670},[103,1951,1787],{"class":515},[103,1953,261],{"class":519},[103,1955,883],{"class":124},[103,1957,1958,1960,1962],{"class":105,"line":678},[103,1959,1797],{"class":515},[103,1961,261],{"class":519},[103,1963,1802],{"class":124},[103,1965,1966,1968],{"class":105,"line":687},[103,1967,1807],{"class":515},[103,1969,520],{"class":519},[103,1971,1972,1975,1977],{"class":105,"line":698},[103,1973,1974],{"class":515},"      POSTGRES_DB",[103,1976,261],{"class":519},[103,1978,1979],{"class":124}," miapp\n",[103,1981,1982,1985,1987],{"class":105,"line":706},[103,1983,1984],{"class":515},"      POSTGRES_USER",[103,1986,261],{"class":519},[103,1988,437],{"class":124},[103,1990,1991,1994,1996],{"class":105,"line":714},[103,1992,1993],{"class":515},"      POSTGRES_PASSWORD",[103,1995,261],{"class":519},[103,1997,1998],{"class":124}," ${DB_PASSWORD}\n",[103,2000,2001,2004],{"class":105,"line":722},[103,2002,2003],{"class":515},"    volumes",[103,2005,520],{"class":519},[103,2007,2008,2010],{"class":105,"line":727},[103,2009,681],{"class":519},[103,2011,2012],{"class":124}," pg_data:/var/lib/postgresql/data\n",[103,2014,2015,2018],{"class":105,"line":732},[103,2016,2017],{"class":515},"    healthcheck",[103,2019,520],{"class":519},[103,2021,2022,2025,2027,2029,2031,2034,2036,2039,2041,2044,2046],{"class":105,"line":738},[103,2023,2024],{"class":515},"      test",[103,2026,261],{"class":519},[103,2028,473],{"class":519},[103,2030,1674],{"class":1667},[103,2032,2033],{"class":124},"CMD-SHELL",[103,2035,1674],{"class":1667},[103,2037,2038],{"class":519},",",[103,2040,1668],{"class":1667},[103,2042,2043],{"class":124},"pg_isready -U app -d miapp",[103,2045,1674],{"class":1667},[103,2047,485],{"class":519},[103,2049,2050,2053,2055],{"class":105,"line":743},[103,2051,2052],{"class":515},"      interval",[103,2054,261],{"class":519},[103,2056,2057],{"class":124}," 10s\n",[103,2059,2060,2063,2065],{"class":105,"line":751},[103,2061,2062],{"class":515},"      timeout",[103,2064,261],{"class":519},[103,2066,2067],{"class":124}," 5s\n",[103,2069,2070,2073,2075],{"class":105,"line":760},[103,2071,2072],{"class":515},"      retries",[103,2074,261],{"class":519},[103,2076,2077],{"class":1709}," 5\n",[103,2079,2080,2082],{"class":105,"line":769},[103,2081,1857],{"class":515},[103,2083,520],{"class":519},[103,2085,2086,2088],{"class":105,"line":782},[103,2087,681],{"class":519},[103,2089,1873],{"class":124},[103,2091,2092],{"class":105,"line":789},[103,2093,375],{"emptyLinePlaceholder":374},[103,2095,2096,2099],{"class":105,"line":797},[103,2097,2098],{"class":515},"networks",[103,2100,520],{"class":519},[103,2102,2103,2106],{"class":105,"line":805},[103,2104,2105],{"class":515},"  proxy",[103,2107,520],{"class":519},[103,2109,2110,2113,2115],{"class":105,"line":812},[103,2111,2112],{"class":515},"    external",[103,2114,261],{"class":519},[103,2116,2118],{"class":2117},"syTEX"," true\n",[103,2120,2121,2124],{"class":105,"line":819},[103,2122,2123],{"class":515},"  backend",[103,2125,520],{"class":519},[103,2127,2128],{"class":105,"line":826},[103,2129,375],{"emptyLinePlaceholder":374},[103,2131,2132,2135],{"class":105,"line":831},[103,2133,2134],{"class":515},"volumes",[103,2136,520],{"class":519},[103,2138,2139,2142],{"class":105,"line":839},[103,2140,2141],{"class":515},"  pg_data",[103,2143,520],{"class":519},[34,2145,2147],{"id":2146},"paso-6-ejecutar-el-primer-pipeline","Paso 6: Ejecutar el primer pipeline",[12,2149,2150],{},"Haz un commit y push:",[39,2152,2154],{"className":97,"code":2153,"language":99,"meta":47,"style":47},"git add .gitlab-ci.yml Dockerfile\ngit commit -m \"feat: agregar pipeline CI/CD\"\ngit push origin main\n",[16,2155,2156,2170,2187],{"__ignoreMap":47},[103,2157,2158,2161,2164,2167],{"class":105,"line":106},[103,2159,2160],{"class":116},"git",[103,2162,2163],{"class":124}," add",[103,2165,2166],{"class":124}," .gitlab-ci.yml",[103,2168,2169],{"class":124}," Dockerfile\n",[103,2171,2172,2174,2177,2180,2182,2185],{"class":105,"line":113},[103,2173,2160],{"class":116},[103,2175,2176],{"class":124}," commit",[103,2178,2179],{"class":120}," -m",[103,2181,1668],{"class":1667},[103,2183,2184],{"class":124},"feat: agregar pipeline CI/CD",[103,2186,1892],{"class":1667},[103,2188,2189,2191,2194,2197],{"class":105,"line":138},[103,2190,2160],{"class":116},[103,2192,2193],{"class":124}," push",[103,2195,2196],{"class":124}," origin",[103,2198,711],{"class":124},[12,2200,2201,2202,2205],{},"Ve a ",[55,2203,2204],{},"CI/CD → Pipelines"," en GitLab. Verás tu pipeline ejecutándose con las 4 etapas. Si build y test pasan, la etapa de push sube la imagen al registry. El deploy espera tu click manual.",[12,2207,2208,2209,2211],{},"Haz click en el botón ▶️ del job ",[16,2210,1123],{}," para desplegar.",[34,2213,2215],{"id":2214},"paso-7-pipeline-para-merge-requests","Paso 7: Pipeline para merge requests",[12,2217,2218,2219,261],{},"Una práctica esencial es ejecutar build y tests en cada merge request para que los problemas se detecten antes de mergear a ",[16,2220,31],{},[12,2222,2223,2224,2227],{},"El pipeline ya está configurado con ",[16,2225,2226],{},"only: - merge_requests"," en las etapas de build y test. Cuando alguien abre un MR, GitLab ejecuta automáticamente el build y las pruebas. Si fallan, el MR se marca con ❌ y no se puede mergear.",[487,2229],{},[34,2231,2233],{"id":2232},"optimizaciones","Optimizaciones",[1425,2235,2237],{"id":2236},"caché-de-dependencias","Caché de dependencias",[12,2239,2240],{},"Evita descargar node_modules en cada pipeline:",[39,2242,2244],{"className":501,"code":2243,"language":503,"meta":47,"style":47},"test:unit:\n  cache:\n    key: ${CI_COMMIT_REF_SLUG}\n    paths:\n      - node_modules/\n  script:\n    - npm ci\n    - npm run test\n",[16,2245,2246,2252,2259,2269,2275,2282,2288,2294],{"__ignoreMap":47},[103,2247,2248,2250],{"class":105,"line":106},[103,2249,834],{"class":515},[103,2251,520],{"class":519},[103,2253,2254,2257],{"class":105,"line":113},[103,2255,2256],{"class":515},"  cache",[103,2258,520],{"class":519},[103,2260,2261,2264,2266],{"class":105,"line":138},[103,2262,2263],{"class":515},"    key",[103,2265,261],{"class":519},[103,2267,2268],{"class":124}," ${CI_COMMIT_REF_SLUG}\n",[103,2270,2271,2273],{"class":105,"line":328},[103,2272,673],{"class":515},[103,2274,520],{"class":519},[103,2276,2277,2279],{"class":105,"line":337},[103,2278,681],{"class":519},[103,2280,2281],{"class":124}," node_modules/\n",[103,2283,2284,2286],{"class":105,"line":346},[103,2285,642],{"class":515},[103,2287,520],{"class":519},[103,2289,2290,2292],{"class":105,"line":355},[103,2291,649],{"class":519},[103,2293,794],{"class":124},[103,2295,2296,2298],{"class":105,"line":363},[103,2297,649],{"class":519},[103,2299,954],{"class":124},[1425,2301,2303],{"id":2302},"notificaciones-a-slack","Notificaciones a Slack",[12,2305,2306],{},"Agrega un job de notificación al final del pipeline:",[39,2308,2310],{"className":501,"code":2309,"language":503,"meta":47,"style":47},"notify:\n  stage: .post\n  image: alpine:latest\n  script:\n    - apk add --no-cache curl\n    - |\n      curl -X POST \"$SLACK_WEBHOOK\" \\\n        -H \"Content-Type: application/json\" \\\n        -d \"{\\\"text\\\":\\\"✅ Deploy $CI_COMMIT_SHORT_SHA completado en producción por $GITLAB_USER_NAME\\\"}\"\n  only:\n    - main\n  when: on_success\n",[16,2311,2312,2319,2328,2336,2342,2349,2355,2360,2365,2370,2376,2382],{"__ignoreMap":47},[103,2313,2314,2317],{"class":105,"line":106},[103,2315,2316],{"class":515},"notify",[103,2318,520],{"class":519},[103,2320,2321,2323,2325],{"class":105,"line":113},[103,2322,610],{"class":515},[103,2324,261],{"class":519},[103,2326,2327],{"class":124}," .post\n",[103,2329,2330,2332,2334],{"class":105,"line":138},[103,2331,619],{"class":515},[103,2333,261],{"class":519},[103,2335,1144],{"class":124},[103,2337,2338,2340],{"class":105,"line":328},[103,2339,642],{"class":515},[103,2341,520],{"class":519},[103,2343,2344,2346],{"class":105,"line":337},[103,2345,649],{"class":519},[103,2347,2348],{"class":124}," apk add --no-cache curl\n",[103,2350,2351,2353],{"class":105,"line":346},[103,2352,649],{"class":519},[103,2354,1220],{"class":1219},[103,2356,2357],{"class":105,"line":355},[103,2358,2359],{"class":124},"      curl -X POST \"$SLACK_WEBHOOK\" \\\n",[103,2361,2362],{"class":105,"line":363},[103,2363,2364],{"class":124},"        -H \"Content-Type: application/json\" \\\n",[103,2366,2367],{"class":105,"line":371},[103,2368,2369],{"class":124},"        -d \"{\\\"text\\\":\\\"✅ Deploy $CI_COMMIT_SHORT_SHA completado en producción por $GITLAB_USER_NAME\\\"}\"\n",[103,2371,2372,2374],{"class":105,"line":378},[103,2373,701],{"class":515},[103,2375,520],{"class":519},[103,2377,2378,2380],{"class":105,"line":384},[103,2379,649],{"class":519},[103,2381,711],{"class":124},[103,2383,2384,2386,2388],{"class":105,"line":392},[103,2385,1415],{"class":515},[103,2387,261],{"class":519},[103,2389,2390],{"class":124}," on_success\n",[1425,2392,2394],{"id":2393},"rollback-rápido","Rollback rápido",[12,2396,2397,2398,2401],{},"Si un deploy sale mal, vuelve a la versión anterior con un click. En ",[55,2399,2400],{},"CI/CD → Environments → production",", GitLab muestra el historial de deploys. Haz click en \"Re-deploy\" en la versión anterior.",[12,2403,2404],{},"O desde la terminal:",[39,2406,2408],{"className":97,"code":2407,"language":99,"meta":47,"style":47},"ssh deploy@tu-servidor \"cd /opt/mi-app && docker compose pull app && docker compose up -d\"\n",[16,2409,2410],{"__ignoreMap":47},[103,2411,2412,2415,2418,2420,2423],{"class":105,"line":106},[103,2413,2414],{"class":116},"ssh",[103,2416,2417],{"class":124}," deploy@tu-servidor",[103,2419,1668],{"class":1667},[103,2421,2422],{"class":124},"cd /opt/mi-app && docker compose pull app && docker compose up -d",[103,2424,1892],{"class":1667},[12,2426,2427],{},"Cambiando el tag de la imagen al commit anterior.",[34,2429,2431],{"id":2430},"siguientes-pasos","Siguientes pasos",[12,2433,2434],{},"Con el pipeline básico funcionando, puedes expandir:",[175,2436,2437,2443,2449,2462,2468,2477],{},[52,2438,2439,2442],{},[55,2440,2441],{},"Stages de staging"," — deploy automático a un ambiente de pruebas antes de producción",[52,2444,2445,2448],{},[55,2446,2447],{},"Feature flags"," — desplegar código inactivo y activarlo gradualmente",[52,2450,2451,2457,2458,2461],{},[55,2452,2453],{},[24,2454,2456],{"href":2455},"/tecnologias/kubernetes","Kubernetes"," — deploy a un cluster K8s con ",[16,2459,2460],{},"kubectl apply"," o Helm charts",[52,2463,2464,2467],{},[55,2465,2466],{},"Seguridad en el pipeline"," — escaneo de vulnerabilidades en imágenes Docker con Trivy",[52,2469,2470,2476],{},[55,2471,2472],{},[24,2473,2475],{"href":2474},"/blog/monitoreo-grafana-prometheus","Monitoreo post-deploy"," — verificar métricas de la aplicación después de cada deploy",[52,2478,2479,2485],{},[55,2480,2481],{},[24,2482,2484],{"href":2483},"/soluciones/automatizacion","Automatización"," avanzada — pipelines que disparan migraciones de base de datos, limpieza de caché y notificaciones a stakeholders",[2487,2488],"call-to-action",{"description":2489,"eyebrow":2490,"icon":2491,"label":2492,"title":2493,"to":2494},"Implementamos CI/CD con GitLab o GitHub Actions para que tu equipo despliegue con confianza, sin errores manuales y con rollback automático.","DevOps profesional","i-lucide-rocket","Solicitar evaluación","¿Necesitas pipelines CI/CD para tu equipo?","/contacto",[2496,2497,2498],"style",{},"html pre.shiki code .sutJx, html code.shiki .sutJx{--shiki-light:#90A4AE;--shiki-light-font-style:italic;--shiki-default:#6A737D;--shiki-default-font-style:inherit;--shiki-dark:#6A737D;--shiki-dark-font-style:inherit}html pre.shiki code .sbgvK, html code.shiki .sbgvK{--shiki-light:#E2931D;--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .stzsN, html code.shiki .stzsN{--shiki-light:#91B859;--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .s_sjI, html code.shiki .s_sjI{--shiki-light:#91B859;--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .smGrS, html code.shiki .smGrS{--shiki-light:#39ADB5;--shiki-default:#D73A49;--shiki-dark:#F97583}html .light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html.light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sw1J6, html code.shiki .sw1J6{--shiki-light:#F76D47;--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .su5hD, html code.shiki .su5hD{--shiki-light:#90A4AE;--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sQzsp, html code.shiki .sQzsp{--shiki-light:#E53935;--shiki-default:#22863A;--shiki-dark:#85E89D}html pre.shiki code .sP7_E, html code.shiki .sP7_E{--shiki-light:#39ADB5;--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sVHd0, html code.shiki .sVHd0{--shiki-light:#39ADB5;--shiki-light-font-style:italic;--shiki-default:#D73A49;--shiki-default-font-style:inherit;--shiki-dark:#F97583;--shiki-dark-font-style:inherit}html pre.shiki code .sptTA, html code.shiki .sptTA{--shiki-light:#6182B8;--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sjJ54, html code.shiki .sjJ54{--shiki-light:#39ADB5;--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .srdBf, html code.shiki .srdBf{--shiki-light:#F76D47;--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .syTEX, html code.shiki .syTEX{--shiki-light:#FF5370;--shiki-default:#005CC5;--shiki-dark:#79B8FF}",{"title":47,"searchDepth":113,"depth":138,"links":2500},[2501,2502,2503,2504,2505,2509,2510,2511,2512,2513,2518],{"id":36,"depth":113,"text":37},{"id":79,"depth":113,"text":80},{"id":90,"depth":113,"text":91},{"id":281,"depth":113,"text":282},{"id":491,"depth":113,"text":2506,"children":2507},"Paso 3: El archivo .gitlab-ci.yml",[2508],{"id":1427,"depth":138,"text":1428},{"id":1476,"depth":113,"text":1477},{"id":1590,"depth":113,"text":1591},{"id":2146,"depth":113,"text":2147},{"id":2214,"depth":113,"text":2215},{"id":2232,"depth":113,"text":2233,"children":2514},[2515,2516,2517],{"id":2236,"depth":138,"text":2237},{"id":2302,"depth":138,"text":2303},{"id":2393,"depth":138,"text":2394},{"id":2430,"depth":113,"text":2431},"tutorial",{"title":2521,"description":2522,"label":2492,"to":2494,"icon":2491},"¿Necesitas automatizar tus despliegues?","Implementamos pipelines CI/CD profesionales para que tu equipo despliegue con confianza — sin errores manuales y con rollback automático.","2026-02-12","Guía paso a paso para configurar pipelines de CI/CD en GitLab que compilen, prueben y desplieguen tu aplicación automáticamente en cada push a producción.",false,"md",[2528,2531,2534,2537],{"question":2529,"answer":2530},"¿Qué es CI/CD y para qué sirve?","CI (Integración Continua) significa que cada cambio de código se compila y prueba automáticamente. CD (Despliegue Continuo) significa que si las pruebas pasan, el código se despliega automáticamente a producción. Juntos eliminan los despliegues manuales, los errores humanos y el miedo a hacer releases. Lo que antes tomaba horas de trabajo manual se convierte en un push a git.",{"question":2532,"answer":2533},"¿Puedo usar GitLab CI/CD con servidores propios?","Sí. GitLab soporta runners auto-hospedados que corren en tu propia infraestructura. Esto te da control total sobre el entorno de build, acceso a redes privadas y sin límites de minutos de ejecución. En esta guía configuramos un runner con Docker executor en tu propio servidor.",{"question":2535,"answer":2536},"¿GitLab CI/CD es gratuito?","Sí. GitLab Free incluye 400 minutos de CI/CD al mes en runners compartidos de GitLab.com. Con runners auto-hospedados (como configuramos en esta guía) no hay límite de minutos. Las funcionalidades de CI/CD están disponibles en todos los planes incluyendo el gratuito.",{"question":2538,"answer":2539},"¿Puedo usar GitHub Actions en lugar de GitLab CI?","Sí, los conceptos son equivalentes. GitHub Actions usa workflows con YAML similar. Si tu código está en GitHub, usa Actions. Si está en GitLab, usa GitLab CI. Los principios de esta guía aplican a ambos — lo que cambia es la sintaxis.","/images/blog/gitlab-cicd.jpg","Interfaz de GitLab mostrando un pipeline de CI/CD con etapas de build, test y deploy completadas exitosamente",{},"/blog/tutorial/cicd-gitlab-guia-practica",{"title":5,"description":2524},"blog/tutorial/cicd-gitlab-guia-practica",[2547,2548,2549,220,2550,2519],"cicd","gitlab","devops","automatizacion","GdHO_1SpYPzjRPODJEzpb42pfc_ciAf5ZCrD27O0kCE",{"path":2553,"title":2554},"/blog/tutorial/backup-automatizado-python-cron","Backup automatizado con Python y cron en servidores Linux",{"path":2556,"title":2557},"/blog/tutorial/hardening-servidores-linux","Hardening de servidores Linux — 15 pasos esenciales",[2559,2566,2573],{"path":2560,"title":2561,"description":2562,"date":2563,"category":2519,"image":2564,"imageAlt":2565,"readingTime":392},"/blog/tutorial/apis-rest-python-fastapi","Crear APIs REST con Python y FastAPI para integraciones empresariales","Guía paso a paso para construir una API REST profesional con Python y FastAPI que conecte tu ERP, CRM o cualquier sistema con validación, autenticación y documentación automática.","2026-03-04","/images/blog/fastapi-api-rest.jpg","Editor de código mostrando una API FastAPI con documentación Swagger generada automáticamente",{"path":2567,"title":2568,"description":2569,"date":2570,"category":2519,"image":2571,"imageAlt":2572,"readingTime":363},"/blog/tutorial/configurar-firewall-ufw-linux","Configurar firewall en Linux con UFW — reglas esenciales","Guía paso a paso para configurar UFW (Uncomplicated Firewall) en Ubuntu y Debian con las reglas esenciales para proteger servidores de producción.","2026-03-01","/images/blog/ufw-firewall-linux.jpg","Terminal de Linux mostrando reglas de firewall UFW activas protegiendo un servidor de producción",{"path":2574,"title":2575,"description":2576,"date":2577,"category":2519,"image":2578,"imageAlt":2579,"readingTime":378},"/blog/tutorial/traefik-reverse-proxy-docker","Configurar Traefik como reverse proxy para contenedores Docker","Guía paso a paso para instalar Traefik como reverse proxy con descubrimiento automático de contenedores Docker, SSL con Let's Encrypt y dashboard de monitoreo.","2026-02-28","/images/blog/traefik-docker.jpg","Dashboard de Traefik mostrando rutas automáticas hacia múltiples contenedores Docker con SSL activo"]