[{"data":1,"prerenderedAt":1612},["ShallowReactive",2],{"page-en-\u002Fblog\u002Fgithub-actions-ci-cd":3,"related-en-\u002Fblog\u002Fgithub-actions-ci-cd":1589},{"id":4,"title":5,"body":6,"date":1575,"description":1576,"extension":1577,"meta":1578,"navigation":194,"path":1579,"seo":1580,"stem":1581,"tags":1582,"translation":1587,"__hash__":1588},"pages_en\u002Fen\u002Fblog\u002Fgithub-actions-ci-cd.md","CI\u002FCD with GitHub Actions — Build, Test and Deploy to Your Server",{"type":7,"value":8,"toc":1562},"minimark",[9,14,23,26,40,43,47,152,156,393,396,462,469,473,607,610,614,626,705,716,723,776,780,1120,1123,1156,1160,1168,1208,1211,1215,1218,1389,1393,1472,1476,1544,1548,1558],[10,11,13],"h2",{"id":12},"introduction","Introduction",[15,16,17,18,22],"p",{},"GitHub Actions is a CI\u002FCD system defined by ",[19,20,21],"code",{},".github\u002Fworkflows\u002F*.yml"," files in the repo and run on GitHub's own infrastructure. It's free for public repos, and private repos get a monthly quota of free minutes.",[15,24,25],{},"In this post we'll set up this flow:",[27,28,29,33],"ol",{},[30,31,32],"li",{},"On every push and pull request: install dependencies, build, run tests.",[30,34,35,36,39],{},"On merge to ",[19,37,38],{},"main",": copy the output to our own server over SSH and restart the app.",[15,41,42],{},"This blog is deployed exactly this way.",[10,44,46],{"id":45},"_1-core-concepts","1. Core concepts",[48,49,50,63],"table",{},[51,52,53],"thead",{},[54,55,56,60],"tr",{},[57,58,59],"th",{},"Concept",[57,61,62],{},"Meaning",[64,65,66,78,103,111,126,144],"tbody",{},[54,67,68,72],{},[69,70,71],"td",{},"Workflow",[69,73,74,75],{},"A YAML file under ",[19,76,77],{},".github\u002Fworkflows\u002F",[54,79,80,87],{},[69,81,82,83,86],{},"Event (",[19,84,85],{},"on",")",[69,88,89,90,93,94,93,97,93,100],{},"What triggers the workflow: ",[19,91,92],{},"push",", ",[19,95,96],{},"pull_request",[19,98,99],{},"schedule",[19,101,102],{},"workflow_dispatch",[54,104,105,108],{},[69,106,107],{},"Job",[69,109,110],{},"A group of steps run in order on one runner; jobs run in parallel by default",[54,112,113,116],{},[69,114,115],{},"Step",[69,117,118,119,122,123,86],{},"A single command (",[19,120,121],{},"run",") or a ready-made action (",[19,124,125],{},"uses",[54,127,128,131],{},[69,129,130],{},"Runner",[69,132,133,134,93,137,93,140,143],{},"The machine running the job: ",[19,135,136],{},"ubuntu-latest",[19,138,139],{},"windows-latest",[19,141,142],{},"macos-latest"," or self-hosted",[54,145,146,149],{},[69,147,148],{},"Secret",[69,150,151],{},"A value stored in repo settings and masked in logs",[10,153,155],{"id":154},"_2-ci-build-and-test","2. CI: build and test",[157,158,163],"pre",{"className":159,"code":160,"language":161,"meta":162,"style":162},"language-yaml shiki shiki-themes material-theme-lighter material-theme material-theme-palenight","# .github\u002Fworkflows\u002Fci.yml\nname: CI\n\non:\n  push:\n    branches: [main]\n  pull_request:\n\njobs:\n  build:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions\u002Fcheckout@v4\n\n      - uses: actions\u002Fsetup-node@v4\n        with:\n          node-version: 22\n          cache: npm\n\n      - run: npm ci\n      - run: npm run lint\n      - run: npm test\n      - run: npm run build\n","yaml","",[19,164,165,174,189,196,205,213,229,237,242,250,258,269,277,291,296,308,316,328,339,344,357,369,381],{"__ignoreMap":162},[166,167,170],"span",{"class":168,"line":169},"line",1,[166,171,173],{"class":172},"sHwdD","# .github\u002Fworkflows\u002Fci.yml\n",[166,175,177,181,185],{"class":168,"line":176},2,[166,178,180],{"class":179},"swJcz","name",[166,182,184],{"class":183},"sMK4o",":",[166,186,188],{"class":187},"sfazB"," CI\n",[166,190,192],{"class":168,"line":191},3,[166,193,195],{"emptyLinePlaceholder":194},true,"\n",[166,197,199,202],{"class":168,"line":198},4,[166,200,85],{"class":201},"sfNiH",[166,203,204],{"class":183},":\n",[166,206,208,211],{"class":168,"line":207},5,[166,209,210],{"class":179},"  push",[166,212,204],{"class":183},[166,214,216,219,221,224,226],{"class":168,"line":215},6,[166,217,218],{"class":179},"    branches",[166,220,184],{"class":183},[166,222,223],{"class":183}," [",[166,225,38],{"class":187},[166,227,228],{"class":183},"]\n",[166,230,232,235],{"class":168,"line":231},7,[166,233,234],{"class":179},"  pull_request",[166,236,204],{"class":183},[166,238,240],{"class":168,"line":239},8,[166,241,195],{"emptyLinePlaceholder":194},[166,243,245,248],{"class":168,"line":244},9,[166,246,247],{"class":179},"jobs",[166,249,204],{"class":183},[166,251,253,256],{"class":168,"line":252},10,[166,254,255],{"class":179},"  build",[166,257,204],{"class":183},[166,259,261,264,266],{"class":168,"line":260},11,[166,262,263],{"class":179},"    runs-on",[166,265,184],{"class":183},[166,267,268],{"class":187}," ubuntu-latest\n",[166,270,272,275],{"class":168,"line":271},12,[166,273,274],{"class":179},"    steps",[166,276,204],{"class":183},[166,278,280,283,286,288],{"class":168,"line":279},13,[166,281,282],{"class":183},"      -",[166,284,285],{"class":179}," uses",[166,287,184],{"class":183},[166,289,290],{"class":187}," actions\u002Fcheckout@v4\n",[166,292,294],{"class":168,"line":293},14,[166,295,195],{"emptyLinePlaceholder":194},[166,297,299,301,303,305],{"class":168,"line":298},15,[166,300,282],{"class":183},[166,302,285],{"class":179},[166,304,184],{"class":183},[166,306,307],{"class":187}," actions\u002Fsetup-node@v4\n",[166,309,311,314],{"class":168,"line":310},16,[166,312,313],{"class":179},"        with",[166,315,204],{"class":183},[166,317,319,322,324],{"class":168,"line":318},17,[166,320,321],{"class":179},"          node-version",[166,323,184],{"class":183},[166,325,327],{"class":326},"sbssI"," 22\n",[166,329,331,334,336],{"class":168,"line":330},18,[166,332,333],{"class":179},"          cache",[166,335,184],{"class":183},[166,337,338],{"class":187}," npm\n",[166,340,342],{"class":168,"line":341},19,[166,343,195],{"emptyLinePlaceholder":194},[166,345,347,349,352,354],{"class":168,"line":346},20,[166,348,282],{"class":183},[166,350,351],{"class":179}," run",[166,353,184],{"class":183},[166,355,356],{"class":187}," npm ci\n",[166,358,360,362,364,366],{"class":168,"line":359},21,[166,361,282],{"class":183},[166,363,351],{"class":179},[166,365,184],{"class":183},[166,367,368],{"class":187}," npm run lint\n",[166,370,372,374,376,378],{"class":168,"line":371},22,[166,373,282],{"class":183},[166,375,351],{"class":179},[166,377,184],{"class":183},[166,379,380],{"class":187}," npm test\n",[166,382,384,386,388,390],{"class":168,"line":383},23,[166,385,282],{"class":183},[166,387,351],{"class":179},[166,389,184],{"class":183},[166,391,392],{"class":187}," npm run build\n",[15,394,395],{},"The same skeleton for a Java\u002FMaven project:",[157,397,399],{"className":159,"code":398,"language":161,"meta":162,"style":162},"      - uses: actions\u002Fsetup-java@v4\n        with:\n          distribution: temurin\n          java-version: 21\n          cache: maven\n\n      - run: .\u002Fmvnw -B verify\n",[19,400,401,412,418,428,438,447,451],{"__ignoreMap":162},[166,402,403,405,407,409],{"class":168,"line":169},[166,404,282],{"class":183},[166,406,285],{"class":179},[166,408,184],{"class":183},[166,410,411],{"class":187}," actions\u002Fsetup-java@v4\n",[166,413,414,416],{"class":168,"line":176},[166,415,313],{"class":179},[166,417,204],{"class":183},[166,419,420,423,425],{"class":168,"line":191},[166,421,422],{"class":179},"          distribution",[166,424,184],{"class":183},[166,426,427],{"class":187}," temurin\n",[166,429,430,433,435],{"class":168,"line":198},[166,431,432],{"class":179},"          java-version",[166,434,184],{"class":183},[166,436,437],{"class":326}," 21\n",[166,439,440,442,444],{"class":168,"line":207},[166,441,333],{"class":179},[166,443,184],{"class":183},[166,445,446],{"class":187}," maven\n",[166,448,449],{"class":168,"line":215},[166,450,195],{"emptyLinePlaceholder":194},[166,452,453,455,457,459],{"class":168,"line":231},[166,454,282],{"class":183},[166,456,351],{"class":179},[166,458,184],{"class":183},[166,460,461],{"class":187}," .\u002Fmvnw -B verify\n",[15,463,464,465,468],{},"The ",[19,466,467],{},"cache"," parameter keeps dependencies (npm, Maven, Gradle) between runs and cuts build time considerably.",[10,470,472],{"id":471},"_3-testing-on-several-versions-with-a-matrix","3. Testing on several versions with a matrix",[157,474,476],{"className":159,"code":475,"language":161,"meta":162,"style":162},"jobs:\n  test:\n    runs-on: ubuntu-latest\n    strategy:\n      matrix:\n        node: [20, 22, 24]\n    steps:\n      - uses: actions\u002Fcheckout@v4\n      - uses: actions\u002Fsetup-node@v4\n        with:\n          node-version: ${{ matrix.node }}\n          cache: npm\n      - run: npm ci\n      - run: npm test\n",[19,477,478,484,491,499,506,513,538,544,554,564,570,579,587,597],{"__ignoreMap":162},[166,479,480,482],{"class":168,"line":169},[166,481,247],{"class":179},[166,483,204],{"class":183},[166,485,486,489],{"class":168,"line":176},[166,487,488],{"class":179},"  test",[166,490,204],{"class":183},[166,492,493,495,497],{"class":168,"line":191},[166,494,263],{"class":179},[166,496,184],{"class":183},[166,498,268],{"class":187},[166,500,501,504],{"class":168,"line":198},[166,502,503],{"class":179},"    strategy",[166,505,204],{"class":183},[166,507,508,511],{"class":168,"line":207},[166,509,510],{"class":179},"      matrix",[166,512,204],{"class":183},[166,514,515,518,520,522,525,528,531,533,536],{"class":168,"line":215},[166,516,517],{"class":179},"        node",[166,519,184],{"class":183},[166,521,223],{"class":183},[166,523,524],{"class":326},"20",[166,526,527],{"class":183},",",[166,529,530],{"class":326}," 22",[166,532,527],{"class":183},[166,534,535],{"class":326}," 24",[166,537,228],{"class":183},[166,539,540,542],{"class":168,"line":231},[166,541,274],{"class":179},[166,543,204],{"class":183},[166,545,546,548,550,552],{"class":168,"line":239},[166,547,282],{"class":183},[166,549,285],{"class":179},[166,551,184],{"class":183},[166,553,290],{"class":187},[166,555,556,558,560,562],{"class":168,"line":244},[166,557,282],{"class":183},[166,559,285],{"class":179},[166,561,184],{"class":183},[166,563,307],{"class":187},[166,565,566,568],{"class":168,"line":252},[166,567,313],{"class":179},[166,569,204],{"class":183},[166,571,572,574,576],{"class":168,"line":260},[166,573,321],{"class":179},[166,575,184],{"class":183},[166,577,578],{"class":187}," ${{ matrix.node }}\n",[166,580,581,583,585],{"class":168,"line":271},[166,582,333],{"class":179},[166,584,184],{"class":183},[166,586,338],{"class":187},[166,588,589,591,593,595],{"class":168,"line":279},[166,590,282],{"class":183},[166,592,351],{"class":179},[166,594,184],{"class":183},[166,596,356],{"class":187},[166,598,599,601,603,605],{"class":168,"line":293},[166,600,282],{"class":183},[166,602,351],{"class":179},[166,604,184],{"class":183},[166,606,380],{"class":187},[15,608,609],{},"This runs the same job on three Node versions in parallel.",[10,611,613],{"id":612},"_4-secrets","4. Secrets",[15,615,616,617,621,622,625],{},"Values like the server address or an SSH key never go into the repo; add them under ",[618,619,620],"strong",{},"Settings → Secrets and variables → Actions"," or with the ",[19,623,624],{},"gh"," CLI:",[157,627,631],{"className":628,"code":629,"language":630,"meta":162,"style":162},"language-bash shiki shiki-themes material-theme-lighter material-theme material-theme-palenight","gh secret set DEPLOY_HOST --body \"203.0.113.10\"\ngh secret set DEPLOY_USER --body \"deploy\"\ngh secret set DEPLOY_SSH_KEY \u003C ~\u002F.ssh\u002Fdeploy_key\ngh secret list\n","bash",[19,632,633,659,679,696],{"__ignoreMap":162},[166,634,635,638,641,644,647,650,653,656],{"class":168,"line":169},[166,636,624],{"class":637},"sBMFI",[166,639,640],{"class":187}," secret",[166,642,643],{"class":187}," set",[166,645,646],{"class":187}," DEPLOY_HOST",[166,648,649],{"class":187}," --body",[166,651,652],{"class":183}," \"",[166,654,655],{"class":187},"203.0.113.10",[166,657,658],{"class":183},"\"\n",[166,660,661,663,665,667,670,672,674,677],{"class":168,"line":176},[166,662,624],{"class":637},[166,664,640],{"class":187},[166,666,643],{"class":187},[166,668,669],{"class":187}," DEPLOY_USER",[166,671,649],{"class":187},[166,673,652],{"class":183},[166,675,676],{"class":187},"deploy",[166,678,658],{"class":183},[166,680,681,683,685,687,690,693],{"class":168,"line":191},[166,682,624],{"class":637},[166,684,640],{"class":187},[166,686,643],{"class":187},[166,688,689],{"class":187}," DEPLOY_SSH_KEY",[166,691,692],{"class":183}," \u003C",[166,694,695],{"class":187}," ~\u002F.ssh\u002Fdeploy_key\n",[166,697,698,700,702],{"class":168,"line":198},[166,699,624],{"class":637},[166,701,640],{"class":187},[166,703,704],{"class":187}," list\n",[15,706,707,708,711,712,715],{},"Inside a workflow they're used as ",[19,709,710],{},"${{ secrets.DEPLOY_HOST }}",". Secrets are masked as ",[19,713,714],{},"***"," in logs and aren't passed to PRs from forks.",[15,717,718,719,722],{},"Generate a dedicated key pair for deployment and add the public key to the deploy user's ",[19,720,721],{},"~\u002F.ssh\u002Fauthorized_keys"," on the server:",[157,724,726],{"className":628,"code":725,"language":630,"meta":162,"style":162},"ssh-keygen -t ed25519 -f ~\u002F.ssh\u002Fdeploy_key -C \"github-actions\" -N \"\"\nssh-copy-id -i ~\u002F.ssh\u002Fdeploy_key.pub deploy@203.0.113.10\n",[19,727,728,762],{"__ignoreMap":162},[166,729,730,733,736,739,742,745,748,750,753,756,759],{"class":168,"line":169},[166,731,732],{"class":637},"ssh-keygen",[166,734,735],{"class":187}," -t",[166,737,738],{"class":187}," ed25519",[166,740,741],{"class":187}," -f",[166,743,744],{"class":187}," ~\u002F.ssh\u002Fdeploy_key",[166,746,747],{"class":187}," -C",[166,749,652],{"class":183},[166,751,752],{"class":187},"github-actions",[166,754,755],{"class":183},"\"",[166,757,758],{"class":187}," -N",[166,760,761],{"class":183}," \"\"\n",[166,763,764,767,770,773],{"class":168,"line":176},[166,765,766],{"class":637},"ssh-copy-id",[166,768,769],{"class":187}," -i",[166,771,772],{"class":187}," ~\u002F.ssh\u002Fdeploy_key.pub",[166,774,775],{"class":187}," deploy@203.0.113.10\n",[10,777,779],{"id":778},"_5-cd-deploying-to-a-server-over-ssh","5. CD: deploying to a server over SSH",[157,781,783],{"className":159,"code":782,"language":161,"meta":162,"style":162},"# .github\u002Fworkflows\u002Fdeploy.yml\nname: Deploy\n\non:\n  push:\n    branches: [main]\n  workflow_dispatch:\n\nconcurrency:\n  group: deploy-production\n  cancel-in-progress: false\n\njobs:\n  deploy:\n    runs-on: ubuntu-latest\n    environment: production\n    steps:\n      - uses: actions\u002Fcheckout@v4\n\n      - uses: actions\u002Fsetup-node@v4\n        with:\n          node-version: 22\n          cache: npm\n\n      - run: npm ci\n      - run: npm test\n      - run: npm run build\n\n      - name: Configure SSH\n        run: |\n          mkdir -p ~\u002F.ssh\n          echo \"${{ secrets.DEPLOY_SSH_KEY }}\" > ~\u002F.ssh\u002Fid_ed25519\n          chmod 600 ~\u002F.ssh\u002Fid_ed25519\n          ssh-keyscan -H \"${{ secrets.DEPLOY_HOST }}\" >> ~\u002F.ssh\u002Fknown_hosts\n\n      - name: Upload\n        run: |\n          rsync -az --delete dist\u002F \\\n            \"${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}:\u002Fvar\u002Fwww\u002Fapp\u002F\"\n\n      - name: Restart\n        run: |\n          ssh \"${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}\" \\\n            \"cd \u002Fvar\u002Fwww\u002Fapp && npm ci --omit=dev && pm2 reload app\"\n",[19,784,785,790,799,803,809,815,827,834,838,845,855,865,869,875,882,890,900,906,916,920,930,936,944,952,957,968,979,990,995,1008,1020,1026,1032,1038,1044,1049,1061,1070,1076,1082,1087,1099,1108,1114],{"__ignoreMap":162},[166,786,787],{"class":168,"line":169},[166,788,789],{"class":172},"# .github\u002Fworkflows\u002Fdeploy.yml\n",[166,791,792,794,796],{"class":168,"line":176},[166,793,180],{"class":179},[166,795,184],{"class":183},[166,797,798],{"class":187}," Deploy\n",[166,800,801],{"class":168,"line":191},[166,802,195],{"emptyLinePlaceholder":194},[166,804,805,807],{"class":168,"line":198},[166,806,85],{"class":201},[166,808,204],{"class":183},[166,810,811,813],{"class":168,"line":207},[166,812,210],{"class":179},[166,814,204],{"class":183},[166,816,817,819,821,823,825],{"class":168,"line":215},[166,818,218],{"class":179},[166,820,184],{"class":183},[166,822,223],{"class":183},[166,824,38],{"class":187},[166,826,228],{"class":183},[166,828,829,832],{"class":168,"line":231},[166,830,831],{"class":179},"  workflow_dispatch",[166,833,204],{"class":183},[166,835,836],{"class":168,"line":239},[166,837,195],{"emptyLinePlaceholder":194},[166,839,840,843],{"class":168,"line":244},[166,841,842],{"class":179},"concurrency",[166,844,204],{"class":183},[166,846,847,850,852],{"class":168,"line":252},[166,848,849],{"class":179},"  group",[166,851,184],{"class":183},[166,853,854],{"class":187}," deploy-production\n",[166,856,857,860,862],{"class":168,"line":260},[166,858,859],{"class":179},"  cancel-in-progress",[166,861,184],{"class":183},[166,863,864],{"class":201}," false\n",[166,866,867],{"class":168,"line":271},[166,868,195],{"emptyLinePlaceholder":194},[166,870,871,873],{"class":168,"line":279},[166,872,247],{"class":179},[166,874,204],{"class":183},[166,876,877,880],{"class":168,"line":293},[166,878,879],{"class":179},"  deploy",[166,881,204],{"class":183},[166,883,884,886,888],{"class":168,"line":298},[166,885,263],{"class":179},[166,887,184],{"class":183},[166,889,268],{"class":187},[166,891,892,895,897],{"class":168,"line":310},[166,893,894],{"class":179},"    environment",[166,896,184],{"class":183},[166,898,899],{"class":187}," production\n",[166,901,902,904],{"class":168,"line":318},[166,903,274],{"class":179},[166,905,204],{"class":183},[166,907,908,910,912,914],{"class":168,"line":330},[166,909,282],{"class":183},[166,911,285],{"class":179},[166,913,184],{"class":183},[166,915,290],{"class":187},[166,917,918],{"class":168,"line":341},[166,919,195],{"emptyLinePlaceholder":194},[166,921,922,924,926,928],{"class":168,"line":346},[166,923,282],{"class":183},[166,925,285],{"class":179},[166,927,184],{"class":183},[166,929,307],{"class":187},[166,931,932,934],{"class":168,"line":359},[166,933,313],{"class":179},[166,935,204],{"class":183},[166,937,938,940,942],{"class":168,"line":371},[166,939,321],{"class":179},[166,941,184],{"class":183},[166,943,327],{"class":326},[166,945,946,948,950],{"class":168,"line":383},[166,947,333],{"class":179},[166,949,184],{"class":183},[166,951,338],{"class":187},[166,953,955],{"class":168,"line":954},24,[166,956,195],{"emptyLinePlaceholder":194},[166,958,960,962,964,966],{"class":168,"line":959},25,[166,961,282],{"class":183},[166,963,351],{"class":179},[166,965,184],{"class":183},[166,967,356],{"class":187},[166,969,971,973,975,977],{"class":168,"line":970},26,[166,972,282],{"class":183},[166,974,351],{"class":179},[166,976,184],{"class":183},[166,978,380],{"class":187},[166,980,982,984,986,988],{"class":168,"line":981},27,[166,983,282],{"class":183},[166,985,351],{"class":179},[166,987,184],{"class":183},[166,989,392],{"class":187},[166,991,993],{"class":168,"line":992},28,[166,994,195],{"emptyLinePlaceholder":194},[166,996,998,1000,1003,1005],{"class":168,"line":997},29,[166,999,282],{"class":183},[166,1001,1002],{"class":179}," name",[166,1004,184],{"class":183},[166,1006,1007],{"class":187}," Configure SSH\n",[166,1009,1011,1014,1016],{"class":168,"line":1010},30,[166,1012,1013],{"class":179},"        run",[166,1015,184],{"class":183},[166,1017,1019],{"class":1018},"s7zQu"," |\n",[166,1021,1023],{"class":168,"line":1022},31,[166,1024,1025],{"class":187},"          mkdir -p ~\u002F.ssh\n",[166,1027,1029],{"class":168,"line":1028},32,[166,1030,1031],{"class":187},"          echo \"${{ secrets.DEPLOY_SSH_KEY }}\" > ~\u002F.ssh\u002Fid_ed25519\n",[166,1033,1035],{"class":168,"line":1034},33,[166,1036,1037],{"class":187},"          chmod 600 ~\u002F.ssh\u002Fid_ed25519\n",[166,1039,1041],{"class":168,"line":1040},34,[166,1042,1043],{"class":187},"          ssh-keyscan -H \"${{ secrets.DEPLOY_HOST }}\" >> ~\u002F.ssh\u002Fknown_hosts\n",[166,1045,1047],{"class":168,"line":1046},35,[166,1048,195],{"emptyLinePlaceholder":194},[166,1050,1052,1054,1056,1058],{"class":168,"line":1051},36,[166,1053,282],{"class":183},[166,1055,1002],{"class":179},[166,1057,184],{"class":183},[166,1059,1060],{"class":187}," Upload\n",[166,1062,1064,1066,1068],{"class":168,"line":1063},37,[166,1065,1013],{"class":179},[166,1067,184],{"class":183},[166,1069,1019],{"class":1018},[166,1071,1073],{"class":168,"line":1072},38,[166,1074,1075],{"class":187},"          rsync -az --delete dist\u002F \\\n",[166,1077,1079],{"class":168,"line":1078},39,[166,1080,1081],{"class":187},"            \"${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}:\u002Fvar\u002Fwww\u002Fapp\u002F\"\n",[166,1083,1085],{"class":168,"line":1084},40,[166,1086,195],{"emptyLinePlaceholder":194},[166,1088,1090,1092,1094,1096],{"class":168,"line":1089},41,[166,1091,282],{"class":183},[166,1093,1002],{"class":179},[166,1095,184],{"class":183},[166,1097,1098],{"class":187}," Restart\n",[166,1100,1102,1104,1106],{"class":168,"line":1101},42,[166,1103,1013],{"class":179},[166,1105,184],{"class":183},[166,1107,1019],{"class":1018},[166,1109,1111],{"class":168,"line":1110},43,[166,1112,1113],{"class":187},"          ssh \"${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}\" \\\n",[166,1115,1117],{"class":168,"line":1116},44,[166,1118,1119],{"class":187},"            \"cd \u002Fvar\u002Fwww\u002Fapp && npm ci --omit=dev && pm2 reload app\"\n",[15,1121,1122],{},"Key points:",[1124,1125,1126,1133,1141,1149],"ul",{},[30,1127,1128,1132],{},[618,1129,1130],{},[19,1131,842],{}," keeps deployments from overlapping when two merges land back to back.",[30,1134,1135,1140],{},[618,1136,1137],{},[19,1138,1139],{},"environment: production"," lets you add approval rules (required reviewers) and environment-specific secrets on GitHub.",[30,1142,1143,1148],{},[618,1144,1145],{},[19,1146,1147],{},"rsync --delete"," also removes files on the server that are no longer in the build output.",[30,1150,1151,1155],{},[618,1152,1153],{},[19,1154,102],{}," adds a button to run the workflow manually from the Actions tab.",[10,1157,1159],{"id":1158},"_6-scheduled-runs","6. Scheduled runs",[15,1161,1162,1164,1165,184],{},[19,1163,99],{}," runs a workflow periodically from a cron expression. Times are in ",[618,1166,1167],{},"UTC",[157,1169,1171],{"className":159,"code":1170,"language":161,"meta":162,"style":162},"on:\n  schedule:\n    - cron: '0 3 * * *'     # every day at 03:00 UTC\n",[19,1172,1173,1179,1186],{"__ignoreMap":162},[166,1174,1175,1177],{"class":168,"line":169},[166,1176,85],{"class":201},[166,1178,204],{"class":183},[166,1180,1181,1184],{"class":168,"line":176},[166,1182,1183],{"class":179},"  schedule",[166,1185,204],{"class":183},[166,1187,1188,1191,1194,1196,1199,1202,1205],{"class":168,"line":191},[166,1189,1190],{"class":183},"    -",[166,1192,1193],{"class":179}," cron",[166,1195,184],{"class":183},[166,1197,1198],{"class":183}," '",[166,1200,1201],{"class":187},"0 3 * * *",[166,1203,1204],{"class":183},"'",[166,1206,1207],{"class":172},"     # every day at 03:00 UTC\n",[15,1209,1210],{},"Handy for nightly tests, dependency scans, or scheduled content publishing like on this blog. Keep in mind it may start a few minutes late at busy times.",[10,1212,1214],{"id":1213},"_7-job-dependencies-and-artifacts","7. Job dependencies and artifacts",[15,1216,1217],{},"To build once and hand the output to a deploy job:",[157,1219,1221],{"className":159,"code":1220,"language":161,"meta":162,"style":162},"jobs:\n  build:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions\u002Fcheckout@v4\n      - run: npm ci && npm run build\n      - uses: actions\u002Fupload-artifact@v4\n        with:\n          name: dist\n          path: dist\u002F\n\n  deploy:\n    needs: build\n    if: github.ref == 'refs\u002Fheads\u002Fmain'\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions\u002Fdownload-artifact@v4\n        with:\n          name: dist\n          path: dist\u002F\n      # ... rsync steps\n",[19,1222,1223,1229,1235,1243,1249,1259,1270,1281,1287,1297,1307,1311,1317,1327,1337,1345,1351,1362,1368,1376,1384],{"__ignoreMap":162},[166,1224,1225,1227],{"class":168,"line":169},[166,1226,247],{"class":179},[166,1228,204],{"class":183},[166,1230,1231,1233],{"class":168,"line":176},[166,1232,255],{"class":179},[166,1234,204],{"class":183},[166,1236,1237,1239,1241],{"class":168,"line":191},[166,1238,263],{"class":179},[166,1240,184],{"class":183},[166,1242,268],{"class":187},[166,1244,1245,1247],{"class":168,"line":198},[166,1246,274],{"class":179},[166,1248,204],{"class":183},[166,1250,1251,1253,1255,1257],{"class":168,"line":207},[166,1252,282],{"class":183},[166,1254,285],{"class":179},[166,1256,184],{"class":183},[166,1258,290],{"class":187},[166,1260,1261,1263,1265,1267],{"class":168,"line":215},[166,1262,282],{"class":183},[166,1264,351],{"class":179},[166,1266,184],{"class":183},[166,1268,1269],{"class":187}," npm ci && npm run build\n",[166,1271,1272,1274,1276,1278],{"class":168,"line":231},[166,1273,282],{"class":183},[166,1275,285],{"class":179},[166,1277,184],{"class":183},[166,1279,1280],{"class":187}," actions\u002Fupload-artifact@v4\n",[166,1282,1283,1285],{"class":168,"line":239},[166,1284,313],{"class":179},[166,1286,204],{"class":183},[166,1288,1289,1292,1294],{"class":168,"line":244},[166,1290,1291],{"class":179},"          name",[166,1293,184],{"class":183},[166,1295,1296],{"class":187}," dist\n",[166,1298,1299,1302,1304],{"class":168,"line":252},[166,1300,1301],{"class":179},"          path",[166,1303,184],{"class":183},[166,1305,1306],{"class":187}," dist\u002F\n",[166,1308,1309],{"class":168,"line":260},[166,1310,195],{"emptyLinePlaceholder":194},[166,1312,1313,1315],{"class":168,"line":271},[166,1314,879],{"class":179},[166,1316,204],{"class":183},[166,1318,1319,1322,1324],{"class":168,"line":279},[166,1320,1321],{"class":179},"    needs",[166,1323,184],{"class":183},[166,1325,1326],{"class":187}," build\n",[166,1328,1329,1332,1334],{"class":168,"line":293},[166,1330,1331],{"class":179},"    if",[166,1333,184],{"class":183},[166,1335,1336],{"class":187}," github.ref == 'refs\u002Fheads\u002Fmain'\n",[166,1338,1339,1341,1343],{"class":168,"line":298},[166,1340,263],{"class":179},[166,1342,184],{"class":183},[166,1344,268],{"class":187},[166,1346,1347,1349],{"class":168,"line":310},[166,1348,274],{"class":179},[166,1350,204],{"class":183},[166,1352,1353,1355,1357,1359],{"class":168,"line":318},[166,1354,282],{"class":183},[166,1356,285],{"class":179},[166,1358,184],{"class":183},[166,1360,1361],{"class":187}," actions\u002Fdownload-artifact@v4\n",[166,1363,1364,1366],{"class":168,"line":330},[166,1365,313],{"class":179},[166,1367,204],{"class":183},[166,1369,1370,1372,1374],{"class":168,"line":341},[166,1371,1291],{"class":179},[166,1373,184],{"class":183},[166,1375,1296],{"class":187},[166,1377,1378,1380,1382],{"class":168,"line":346},[166,1379,1301],{"class":179},[166,1381,184],{"class":183},[166,1383,1306],{"class":187},[166,1385,1386],{"class":168,"line":359},[166,1387,1388],{"class":172},"      # ... rsync steps\n",[10,1390,1392],{"id":1391},"_8-managing-workflows-with-the-gh-cli","8. Managing workflows with the gh CLI",[157,1394,1396],{"className":628,"code":1395,"language":630,"meta":162,"style":162},"gh workflow list\ngh workflow run deploy.yml            # trigger workflow_dispatch\ngh run list --workflow=deploy.yml\ngh run watch                          # follow a running run live\ngh run view --log-failed              # logs of failed steps\ngh run rerun --failed\n",[19,1397,1398,1407,1421,1433,1445,1460],{"__ignoreMap":162},[166,1399,1400,1402,1405],{"class":168,"line":169},[166,1401,624],{"class":637},[166,1403,1404],{"class":187}," workflow",[166,1406,704],{"class":187},[166,1408,1409,1411,1413,1415,1418],{"class":168,"line":176},[166,1410,624],{"class":637},[166,1412,1404],{"class":187},[166,1414,351],{"class":187},[166,1416,1417],{"class":187}," deploy.yml",[166,1419,1420],{"class":172},"            # trigger workflow_dispatch\n",[166,1422,1423,1425,1427,1430],{"class":168,"line":191},[166,1424,624],{"class":637},[166,1426,351],{"class":187},[166,1428,1429],{"class":187}," list",[166,1431,1432],{"class":187}," --workflow=deploy.yml\n",[166,1434,1435,1437,1439,1442],{"class":168,"line":198},[166,1436,624],{"class":637},[166,1438,351],{"class":187},[166,1440,1441],{"class":187}," watch",[166,1443,1444],{"class":172},"                          # follow a running run live\n",[166,1446,1447,1449,1451,1454,1457],{"class":168,"line":207},[166,1448,624],{"class":637},[166,1450,351],{"class":187},[166,1452,1453],{"class":187}," view",[166,1455,1456],{"class":187}," --log-failed",[166,1458,1459],{"class":172},"              # logs of failed steps\n",[166,1461,1462,1464,1466,1469],{"class":168,"line":215},[166,1463,624],{"class":637},[166,1465,351],{"class":187},[166,1467,1468],{"class":187}," rerun",[166,1470,1471],{"class":187}," --failed\n",[10,1473,1475],{"id":1474},"_9-troubleshooting","9. Troubleshooting",[48,1477,1478,1488],{},[51,1479,1480],{},[54,1481,1482,1485],{},[57,1483,1484],{},"Problem",[57,1486,1487],{},"Fix",[64,1489,1490,1503,1516,1524,1536],{},[54,1491,1492,1497],{},[69,1493,1494],{},[19,1495,1496],{},"Permission denied (publickey)",[69,1498,1499,1500],{},"The private key in the secret is missing\u002Fbroken, or the public key isn't in ",[19,1501,1502],{},"authorized_keys",[54,1504,1505,1510],{},[69,1506,1507],{},[19,1508,1509],{},"Host key verification failed",[69,1511,464,1512,1515],{},[19,1513,1514],{},"ssh-keyscan"," step is missing or targets the wrong host",[54,1517,1518,1521],{},[69,1519,1520],{},"A secret comes through empty",[69,1522,1523],{},"Secrets aren't passed to PRs from forks; names are case-sensitive",[54,1525,1526,1529],{},[69,1527,1528],{},"The cache never hits",[69,1530,1531,1532,1535],{},"The lock file (",[19,1533,1534],{},"package-lock.json",") must be committed",[54,1537,1538,1541],{},[69,1539,1540],{},"A scheduled workflow doesn't run",[69,1542,1543],{},"Only the file on the default branch counts; it's auto-disabled in repos with no activity for 60 days",[10,1545,1547],{"id":1546},"conclusion","Conclusion",[15,1549,1550,1551,1553,1554,1557],{},"A few dozen lines of YAML give you a flow where every change is tested and goes live the moment it's merged to ",[19,1552,38],{},". A single ",[19,1555,1556],{},"ci.yml"," is enough to start; add deployment, matrices and scheduled jobs as the need arises. Never committing secrets and using a separate, limited SSH key for deployment are the two rules to follow from day one.",[1559,1560,1561],"style",{},"html pre.shiki code .sHwdD, html code.shiki .sHwdD{--shiki-light:#90A4AE;--shiki-light-font-style:italic;--shiki-default:#546E7A;--shiki-default-font-style:italic;--shiki-dark:#676E95;--shiki-dark-font-style:italic}html pre.shiki code .swJcz, html code.shiki .swJcz{--shiki-light:#E53935;--shiki-default:#F07178;--shiki-dark:#F07178}html pre.shiki code .sMK4o, html code.shiki .sMK4o{--shiki-light:#39ADB5;--shiki-default:#89DDFF;--shiki-dark:#89DDFF}html pre.shiki code .sfazB, html code.shiki .sfazB{--shiki-light:#91B859;--shiki-default:#C3E88D;--shiki-dark:#C3E88D}html pre.shiki code .sfNiH, html code.shiki .sfNiH{--shiki-light:#FF5370;--shiki-default:#FF9CAC;--shiki-dark:#FF9CAC}html pre.shiki code .sbssI, html code.shiki .sbssI{--shiki-light:#F76D47;--shiki-default:#F78C6C;--shiki-dark:#F78C6C}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 .sBMFI, html code.shiki .sBMFI{--shiki-light:#E2931D;--shiki-default:#FFCB6B;--shiki-dark:#FFCB6B}html pre.shiki code .s7zQu, html code.shiki .s7zQu{--shiki-light:#39ADB5;--shiki-light-font-style:italic;--shiki-default:#89DDFF;--shiki-default-font-style:italic;--shiki-dark:#89DDFF;--shiki-dark-font-style:italic}",{"title":162,"searchDepth":176,"depth":176,"links":1563},[1564,1565,1566,1567,1568,1569,1570,1571,1572,1573,1574],{"id":12,"depth":176,"text":13},{"id":45,"depth":176,"text":46},{"id":154,"depth":176,"text":155},{"id":471,"depth":176,"text":472},{"id":612,"depth":176,"text":613},{"id":778,"depth":176,"text":779},{"id":1158,"depth":176,"text":1159},{"id":1213,"depth":176,"text":1214},{"id":1391,"depth":176,"text":1392},{"id":1474,"depth":176,"text":1475},{"id":1546,"depth":176,"text":1547},"2026-10-29","A step-by-step GitHub Actions workflow that builds and tests on every push and deploys to your own server over SSH when changes land on main.","md",{},"\u002Fen\u002Fblog\u002Fgithub-actions-ci-cd",{"title":5,"description":1576},"en\u002Fblog\u002Fgithub-actions-ci-cd",[1583,1584,1585,1586],"github","ci-cd","devops","guide","\u002Fblog\u002Fgithub-actions-ci-cd","AR9QyexYd5_IQMbefGPBCG5_J4HRKU1hXNOiQrWb-_E",[1590,1598,1605],{"path":1591,"title":1592,"description":1593,"date":1594,"tags":1595},"\u002Fen\u002Fblog\u002Fnginx-reverse-proxy-ssl","Setting Up an Nginx Reverse Proxy with Let's Encrypt SSL","Putting a Node.js or Spring Boot app behind Nginx, getting a free SSL certificate with Certbot, and setting up automatic renewal.","2026-10-28",[1596,1597,1585,1586],"nginx","linux",{"path":1599,"title":1600,"description":1601,"date":1602,"tags":1603},"\u002Fen\u002Fblog\u002Fdocker-compose-guide","Running Multiple Services with Docker Compose","Bringing up an app, a database and a cache from a single compose.yaml — services, networks, volumes, healthchecks, env files and everyday commands.","2026-10-27",[1604,1585,1586],"docker",{"path":1606,"title":1607,"description":1608,"date":1609,"tags":1610},"\u002Fen\u002Fblog\u002Fkubectl-commands","Frequently Used kubectl Commands","The kubectl commands that day-to-day Kubernetes work runs on — contexts, pods, deployments, logs, exec, port-forward, rollouts and troubleshooting.","2026-10-26",[1611,1585,1586],"kubernetes",1793234959454]