[{"data":1,"prerenderedAt":1970},["ShallowReactive",2],{"post-/blog/ghcr-laravel-pipeline":3,"related-/blog/ghcr-laravel-pipeline":1969},{"id":4,"title":5,"author":6,"body":7,"category":1952,"description":1953,"draft":1954,"extension":1955,"featured":1954,"meta":1956,"navigation":128,"ogImage":1957,"path":1958,"publishedAt":1959,"readingTime":1960,"seo":1961,"stem":1962,"tags":1963,"updatedAt":1959,"__hash__":1968},"blog/blog/ghcr-laravel-pipeline.md","A stack-agnostic Docker CI/CD pipeline with GitHub Container Registry","Jacques",{"type":8,"value":9,"toc":1941},"minimark",[10,14,17,22,25,65,69,79,82,86,93,99,736,739,783,793,797,800,805,1259,1262,1279,1283,1290,1692,1695,1698,1702,1705,1708,1832,1843,1847,1850,1853,1864,1867,1875,1878,1882,1885,1922,1926,1934,1937],[11,12,13],"p",{},"Every new project has the same early decision: how do I want to deploy this? For a while I was picking per-project — Ploi for most Laravel work, Vercel for Next.js, a custom rsync script for the odd static site. The result was that every project had a slightly different deploy story, I had to remember which was which, and onboarding a new client meant explaining three different conventions.",[11,15,16],{},"Eventually I got tired of that and standardised on a Docker + GitHub Actions + GitHub Container Registry setup that works for Laravel, Next.js, Nuxt, and basically anything else I build. One pattern, one mental model, one place to look when something breaks. This post walks through the whole thing.",[18,19,21],"h2",{"id":20},"the-design-goals","The design goals",[11,23,24],{},"Before showing the pipeline, the constraints I was optimising for:",[26,27,28,36,47,53,59],"ol",{},[29,30,31,35],"li",{},[32,33,34],"strong",{},"Stack-agnostic."," The workflow file should not know or care whether the project is Laravel, Next.js, or a Go service. All stack-specific logic lives in the project's Dockerfile.",[29,37,38,41,42,46],{},[32,39,40],{},"Environment-differentiated."," Same pipeline, different behaviour for staging vs production. Staging deploys on every push to ",[43,44,45],"code",{},"develop","; production deploys on tag push.",[29,48,49,52],{},[32,50,51],{},"No vendor lock-in."," I don't want Heroku-style buildpacks that only work on one platform. The output is a standard OCI image that can run anywhere Docker runs.",[29,54,55,58],{},[32,56,57],{},"Cheap."," Free tier for small projects. GHCR is free for public images and has generous limits for private.",[29,60,61,64],{},[32,62,63],{},"Debuggable."," When it breaks at midnight, I want to be able to understand why without reading through thousands of lines of YAML.",[18,66,68],{"id":67},"the-architecture","The architecture",[70,71,76],"pre",{"className":72,"code":74,"language":75},[73],"language-text","GitHub Repo\n    ↓ (push to develop or tag)\nGitHub Actions\n    ↓ (build Docker image from Dockerfile)\nGitHub Container Registry (ghcr.io)\n    ↓ (deploy trigger)\nTarget environment (Coolify on Pi / AWS ECS / wherever)\n    ↓ (pulls new image, rotates containers)\nLive\n","text",[43,77,74],{"__ignoreMap":78},"",[11,80,81],{},"The key insight: the artifact produced by CI is a tagged Docker image. That image is the unit of deployment. Everything downstream — staging, prod, local reproduction of a bug — pulls the same image.",[18,83,85],{"id":84},"the-reusable-workflow","The reusable workflow",[11,87,88,89,92],{},"I keep this in a dedicated repo (",[43,90,91],{},"hazelbag/gha-workflows",") so I can version it and update every project at once when something changes upstream.",[11,94,95,98],{},[43,96,97],{},".github/workflows/build-and-push.yml",":",[70,100,104],{"className":101,"code":102,"language":103,"meta":78,"style":78},"language-yaml shiki shiki-themes github-dark","name: Build and push Docker image\n\non:\n  workflow_call:\n    inputs:\n      image-name:\n        required: true\n        type: string\n        description: \"Name of the image (e.g. 'my-laravel-app')\"\n      dockerfile:\n        required: false\n        type: string\n        default: \"./Dockerfile\"\n      build-args:\n        required: false\n        type: string\n        default: \"\"\n      platforms:\n        required: false\n        type: string\n        default: \"linux/amd64\"\n\njobs:\n  build:\n    runs-on: ubuntu-latest\n    permissions:\n      contents: read\n      packages: write\n\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v4\n\n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v3\n\n      - name: Log in to GHCR\n        uses: docker/login-action@v3\n        with:\n          registry: ghcr.io\n          username: ${{ github.actor }}\n          password: ${{ secrets.GITHUB_TOKEN }}\n\n      - name: Extract metadata\n        id: meta\n        uses: docker/metadata-action@v5\n        with:\n          images: ghcr.io/${{ github.repository_owner }}/${{ inputs.image-name }}\n          tags: |\n            type=ref,event=branch\n            type=ref,event=pr\n            type=semver,pattern={{version}}\n            type=semver,pattern={{major}}.{{minor}}\n            type=sha,prefix={{branch}}-,format=short\n            type=raw,value=latest,enable={{is_default_branch}}\n\n      - name: Build and push\n        uses: docker/build-push-action@v5\n        with:\n          context: .\n          file: ${{ inputs.dockerfile }}\n          platforms: ${{ inputs.platforms }}\n          push: true\n          tags: ${{ steps.meta.outputs.tags }}\n          labels: ${{ steps.meta.outputs.labels }}\n          build-args: ${{ inputs.build-args }}\n          cache-from: type=gha\n          cache-to: type=gha,mode=max\n","yaml",[43,105,106,123,130,140,148,156,164,175,186,197,205,215,224,235,243,252,261,271,279,288,297,307,312,320,328,339,347,358,369,374,382,395,406,411,423,433,438,450,460,468,479,490,501,506,518,529,539,546,557,569,575,581,587,593,599,605,610,622,632,639,650,661,672,682,692,703,714,725],{"__ignoreMap":78},[107,108,111,115,119],"span",{"class":109,"line":110},"line",1,[107,112,114],{"class":113},"s4JwU","name",[107,116,118],{"class":117},"s95oV",": ",[107,120,122],{"class":121},"sU2Wk","Build and push Docker image\n",[107,124,126],{"class":109,"line":125},2,[107,127,129],{"emptyLinePlaceholder":128},true,"\n",[107,131,133,137],{"class":109,"line":132},3,[107,134,136],{"class":135},"sDLfK","on",[107,138,139],{"class":117},":\n",[107,141,143,146],{"class":109,"line":142},4,[107,144,145],{"class":113},"  workflow_call",[107,147,139],{"class":117},[107,149,151,154],{"class":109,"line":150},5,[107,152,153],{"class":113},"    inputs",[107,155,139],{"class":117},[107,157,159,162],{"class":109,"line":158},6,[107,160,161],{"class":113},"      image-name",[107,163,139],{"class":117},[107,165,167,170,172],{"class":109,"line":166},7,[107,168,169],{"class":113},"        required",[107,171,118],{"class":117},[107,173,174],{"class":135},"true\n",[107,176,178,181,183],{"class":109,"line":177},8,[107,179,180],{"class":113},"        type",[107,182,118],{"class":117},[107,184,185],{"class":121},"string\n",[107,187,189,192,194],{"class":109,"line":188},9,[107,190,191],{"class":113},"        description",[107,193,118],{"class":117},[107,195,196],{"class":121},"\"Name of the image (e.g. 'my-laravel-app')\"\n",[107,198,200,203],{"class":109,"line":199},10,[107,201,202],{"class":113},"      dockerfile",[107,204,139],{"class":117},[107,206,208,210,212],{"class":109,"line":207},11,[107,209,169],{"class":113},[107,211,118],{"class":117},[107,213,214],{"class":135},"false\n",[107,216,218,220,222],{"class":109,"line":217},12,[107,219,180],{"class":113},[107,221,118],{"class":117},[107,223,185],{"class":121},[107,225,227,230,232],{"class":109,"line":226},13,[107,228,229],{"class":113},"        default",[107,231,118],{"class":117},[107,233,234],{"class":121},"\"./Dockerfile\"\n",[107,236,238,241],{"class":109,"line":237},14,[107,239,240],{"class":113},"      build-args",[107,242,139],{"class":117},[107,244,246,248,250],{"class":109,"line":245},15,[107,247,169],{"class":113},[107,249,118],{"class":117},[107,251,214],{"class":135},[107,253,255,257,259],{"class":109,"line":254},16,[107,256,180],{"class":113},[107,258,118],{"class":117},[107,260,185],{"class":121},[107,262,264,266,268],{"class":109,"line":263},17,[107,265,229],{"class":113},[107,267,118],{"class":117},[107,269,270],{"class":121},"\"\"\n",[107,272,274,277],{"class":109,"line":273},18,[107,275,276],{"class":113},"      platforms",[107,278,139],{"class":117},[107,280,282,284,286],{"class":109,"line":281},19,[107,283,169],{"class":113},[107,285,118],{"class":117},[107,287,214],{"class":135},[107,289,291,293,295],{"class":109,"line":290},20,[107,292,180],{"class":113},[107,294,118],{"class":117},[107,296,185],{"class":121},[107,298,300,302,304],{"class":109,"line":299},21,[107,301,229],{"class":113},[107,303,118],{"class":117},[107,305,306],{"class":121},"\"linux/amd64\"\n",[107,308,310],{"class":109,"line":309},22,[107,311,129],{"emptyLinePlaceholder":128},[107,313,315,318],{"class":109,"line":314},23,[107,316,317],{"class":113},"jobs",[107,319,139],{"class":117},[107,321,323,326],{"class":109,"line":322},24,[107,324,325],{"class":113},"  build",[107,327,139],{"class":117},[107,329,331,334,336],{"class":109,"line":330},25,[107,332,333],{"class":113},"    runs-on",[107,335,118],{"class":117},[107,337,338],{"class":121},"ubuntu-latest\n",[107,340,342,345],{"class":109,"line":341},26,[107,343,344],{"class":113},"    permissions",[107,346,139],{"class":117},[107,348,350,353,355],{"class":109,"line":349},27,[107,351,352],{"class":113},"      contents",[107,354,118],{"class":117},[107,356,357],{"class":121},"read\n",[107,359,361,364,366],{"class":109,"line":360},28,[107,362,363],{"class":113},"      packages",[107,365,118],{"class":117},[107,367,368],{"class":121},"write\n",[107,370,372],{"class":109,"line":371},29,[107,373,129],{"emptyLinePlaceholder":128},[107,375,377,380],{"class":109,"line":376},30,[107,378,379],{"class":113},"    steps",[107,381,139],{"class":117},[107,383,385,388,390,392],{"class":109,"line":384},31,[107,386,387],{"class":117},"      - ",[107,389,114],{"class":113},[107,391,118],{"class":117},[107,393,394],{"class":121},"Checkout\n",[107,396,398,401,403],{"class":109,"line":397},32,[107,399,400],{"class":113},"        uses",[107,402,118],{"class":117},[107,404,405],{"class":121},"actions/checkout@v4\n",[107,407,409],{"class":109,"line":408},33,[107,410,129],{"emptyLinePlaceholder":128},[107,412,414,416,418,420],{"class":109,"line":413},34,[107,415,387],{"class":117},[107,417,114],{"class":113},[107,419,118],{"class":117},[107,421,422],{"class":121},"Set up Docker Buildx\n",[107,424,426,428,430],{"class":109,"line":425},35,[107,427,400],{"class":113},[107,429,118],{"class":117},[107,431,432],{"class":121},"docker/setup-buildx-action@v3\n",[107,434,436],{"class":109,"line":435},36,[107,437,129],{"emptyLinePlaceholder":128},[107,439,441,443,445,447],{"class":109,"line":440},37,[107,442,387],{"class":117},[107,444,114],{"class":113},[107,446,118],{"class":117},[107,448,449],{"class":121},"Log in to GHCR\n",[107,451,453,455,457],{"class":109,"line":452},38,[107,454,400],{"class":113},[107,456,118],{"class":117},[107,458,459],{"class":121},"docker/login-action@v3\n",[107,461,463,466],{"class":109,"line":462},39,[107,464,465],{"class":113},"        with",[107,467,139],{"class":117},[107,469,471,474,476],{"class":109,"line":470},40,[107,472,473],{"class":113},"          registry",[107,475,118],{"class":117},[107,477,478],{"class":121},"ghcr.io\n",[107,480,482,485,487],{"class":109,"line":481},41,[107,483,484],{"class":113},"          username",[107,486,118],{"class":117},[107,488,489],{"class":121},"${{ github.actor }}\n",[107,491,493,496,498],{"class":109,"line":492},42,[107,494,495],{"class":113},"          password",[107,497,118],{"class":117},[107,499,500],{"class":121},"${{ secrets.GITHUB_TOKEN }}\n",[107,502,504],{"class":109,"line":503},43,[107,505,129],{"emptyLinePlaceholder":128},[107,507,509,511,513,515],{"class":109,"line":508},44,[107,510,387],{"class":117},[107,512,114],{"class":113},[107,514,118],{"class":117},[107,516,517],{"class":121},"Extract metadata\n",[107,519,521,524,526],{"class":109,"line":520},45,[107,522,523],{"class":113},"        id",[107,525,118],{"class":117},[107,527,528],{"class":121},"meta\n",[107,530,532,534,536],{"class":109,"line":531},46,[107,533,400],{"class":113},[107,535,118],{"class":117},[107,537,538],{"class":121},"docker/metadata-action@v5\n",[107,540,542,544],{"class":109,"line":541},47,[107,543,465],{"class":113},[107,545,139],{"class":117},[107,547,549,552,554],{"class":109,"line":548},48,[107,550,551],{"class":113},"          images",[107,553,118],{"class":117},[107,555,556],{"class":121},"ghcr.io/${{ github.repository_owner }}/${{ inputs.image-name }}\n",[107,558,560,563,565],{"class":109,"line":559},49,[107,561,562],{"class":113},"          tags",[107,564,118],{"class":117},[107,566,568],{"class":567},"snl16","|\n",[107,570,572],{"class":109,"line":571},50,[107,573,574],{"class":121},"            type=ref,event=branch\n",[107,576,578],{"class":109,"line":577},51,[107,579,580],{"class":121},"            type=ref,event=pr\n",[107,582,584],{"class":109,"line":583},52,[107,585,586],{"class":121},"            type=semver,pattern={{version}}\n",[107,588,590],{"class":109,"line":589},53,[107,591,592],{"class":121},"            type=semver,pattern={{major}}.{{minor}}\n",[107,594,596],{"class":109,"line":595},54,[107,597,598],{"class":121},"            type=sha,prefix={{branch}}-,format=short\n",[107,600,602],{"class":109,"line":601},55,[107,603,604],{"class":121},"            type=raw,value=latest,enable={{is_default_branch}}\n",[107,606,608],{"class":109,"line":607},56,[107,609,129],{"emptyLinePlaceholder":128},[107,611,613,615,617,619],{"class":109,"line":612},57,[107,614,387],{"class":117},[107,616,114],{"class":113},[107,618,118],{"class":117},[107,620,621],{"class":121},"Build and push\n",[107,623,625,627,629],{"class":109,"line":624},58,[107,626,400],{"class":113},[107,628,118],{"class":117},[107,630,631],{"class":121},"docker/build-push-action@v5\n",[107,633,635,637],{"class":109,"line":634},59,[107,636,465],{"class":113},[107,638,139],{"class":117},[107,640,642,645,647],{"class":109,"line":641},60,[107,643,644],{"class":113},"          context",[107,646,118],{"class":117},[107,648,649],{"class":135},".\n",[107,651,653,656,658],{"class":109,"line":652},61,[107,654,655],{"class":113},"          file",[107,657,118],{"class":117},[107,659,660],{"class":121},"${{ inputs.dockerfile }}\n",[107,662,664,667,669],{"class":109,"line":663},62,[107,665,666],{"class":113},"          platforms",[107,668,118],{"class":117},[107,670,671],{"class":121},"${{ inputs.platforms }}\n",[107,673,675,678,680],{"class":109,"line":674},63,[107,676,677],{"class":113},"          push",[107,679,118],{"class":117},[107,681,174],{"class":135},[107,683,685,687,689],{"class":109,"line":684},64,[107,686,562],{"class":113},[107,688,118],{"class":117},[107,690,691],{"class":121},"${{ steps.meta.outputs.tags }}\n",[107,693,695,698,700],{"class":109,"line":694},65,[107,696,697],{"class":113},"          labels",[107,699,118],{"class":117},[107,701,702],{"class":121},"${{ steps.meta.outputs.labels }}\n",[107,704,706,709,711],{"class":109,"line":705},66,[107,707,708],{"class":113},"          build-args",[107,710,118],{"class":117},[107,712,713],{"class":121},"${{ inputs.build-args }}\n",[107,715,717,720,722],{"class":109,"line":716},67,[107,718,719],{"class":113},"          cache-from",[107,721,118],{"class":117},[107,723,724],{"class":121},"type=gha\n",[107,726,728,731,733],{"class":109,"line":727},68,[107,729,730],{"class":113},"          cache-to",[107,732,118],{"class":117},[107,734,735],{"class":121},"type=gha,mode=max\n",[11,737,738],{},"Two things doing real work here:",[11,740,741,746,747,749,750,752,753,756,757,749,760,762,763,766,767,770,771,749,774,762,777,766,780,782],{},[32,742,743],{},[43,744,745],{},"docker/metadata-action"," generates image tags from git context automatically. A push to ",[43,748,45],{}," produces ",[43,751,45],{}," and ",[43,754,755],{},"develop-abc123"," tags. A push to ",[43,758,759],{},"main",[43,761,759],{},", ",[43,764,765],{},"latest",", and ",[43,768,769],{},"main-abc123",". A tag push for ",[43,772,773],{},"v1.2.3",[43,775,776],{},"1.2.3",[43,778,779],{},"1.2",[43,781,765],{},". You never manually compute tags.",[11,784,785,788,789,792],{},[32,786,787],{},"GitHub Actions cache"," (",[43,790,791],{},"cache-from: type=gha",") makes subsequent builds drastically faster. A clean Laravel Docker build is maybe 3 minutes on GHA; with warm cache it's closer to 40 seconds.",[18,794,796],{"id":795},"the-per-project-workflow","The per-project workflow",[11,798,799],{},"Each project has its own tiny wrapper that calls the reusable workflow:",[11,801,802,98],{},[43,803,804],{},".github/workflows/ci.yml",[70,806,808],{"className":101,"code":807,"language":103,"meta":78,"style":78},"name: CI\n\non:\n  push:\n    branches: [main, develop]\n    tags: ['v*']\n  pull_request:\n    branches: [main, develop]\n\njobs:\n  test:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n\n      - name: Set up PHP\n        uses: shivammathur/setup-php@v2\n        with:\n          php-version: '8.3'\n          coverage: none\n\n      - name: Install dependencies\n        run: composer install --prefer-dist --no-progress\n\n      - name: Run tests\n        run: php artisan test\n\n  build:\n    needs: test\n    if: github.event_name == 'push'\n    uses: hazelbag/gha-workflows/.github/workflows/build-and-push.yml@main\n    with:\n      image-name: my-laravel-app\n      dockerfile: ./docker/Dockerfile\n    secrets: inherit\n\n  deploy-staging:\n    needs: build\n    if: github.ref == 'refs/heads/develop'\n    runs-on: ubuntu-latest\n    steps:\n      - name: Trigger Coolify deploy\n        run: |\n          curl -X POST \"${{ secrets.COOLIFY_STAGING_WEBHOOK }}\" \\\n            -H \"Authorization: Bearer ${{ secrets.COOLIFY_TOKEN }}\"\n\n  deploy-production:\n    needs: build\n    if: startsWith(github.ref, 'refs/tags/v')\n    runs-on: ubuntu-latest\n    environment: production  # requires manual approval via GitHub environments\n    steps:\n      - name: Trigger production deploy\n        run: |\n          curl -X POST \"${{ secrets.DEPLOY_PROD_WEBHOOK }}\" \\\n            -H \"Authorization: Bearer ${{ secrets.DEPLOY_PROD_TOKEN }}\"\n",[43,809,810,819,823,829,836,853,865,872,886,890,896,903,911,917,928,932,943,952,958,968,978,982,993,1003,1007,1018,1027,1031,1037,1047,1057,1067,1074,1083,1092,1102,1106,1113,1122,1131,1139,1145,1156,1164,1169,1174,1178,1185,1193,1202,1210,1224,1230,1241,1249,1254],{"__ignoreMap":78},[107,811,812,814,816],{"class":109,"line":110},[107,813,114],{"class":113},[107,815,118],{"class":117},[107,817,818],{"class":121},"CI\n",[107,820,821],{"class":109,"line":125},[107,822,129],{"emptyLinePlaceholder":128},[107,824,825,827],{"class":109,"line":132},[107,826,136],{"class":135},[107,828,139],{"class":117},[107,830,831,834],{"class":109,"line":142},[107,832,833],{"class":113},"  push",[107,835,139],{"class":117},[107,837,838,841,844,846,848,850],{"class":109,"line":150},[107,839,840],{"class":113},"    branches",[107,842,843],{"class":117},": [",[107,845,759],{"class":121},[107,847,762],{"class":117},[107,849,45],{"class":121},[107,851,852],{"class":117},"]\n",[107,854,855,858,860,863],{"class":109,"line":158},[107,856,857],{"class":113},"    tags",[107,859,843],{"class":117},[107,861,862],{"class":121},"'v*'",[107,864,852],{"class":117},[107,866,867,870],{"class":109,"line":166},[107,868,869],{"class":113},"  pull_request",[107,871,139],{"class":117},[107,873,874,876,878,880,882,884],{"class":109,"line":177},[107,875,840],{"class":113},[107,877,843],{"class":117},[107,879,759],{"class":121},[107,881,762],{"class":117},[107,883,45],{"class":121},[107,885,852],{"class":117},[107,887,888],{"class":109,"line":188},[107,889,129],{"emptyLinePlaceholder":128},[107,891,892,894],{"class":109,"line":199},[107,893,317],{"class":113},[107,895,139],{"class":117},[107,897,898,901],{"class":109,"line":207},[107,899,900],{"class":113},"  test",[107,902,139],{"class":117},[107,904,905,907,909],{"class":109,"line":217},[107,906,333],{"class":113},[107,908,118],{"class":117},[107,910,338],{"class":121},[107,912,913,915],{"class":109,"line":226},[107,914,379],{"class":113},[107,916,139],{"class":117},[107,918,919,921,924,926],{"class":109,"line":237},[107,920,387],{"class":117},[107,922,923],{"class":113},"uses",[107,925,118],{"class":117},[107,927,405],{"class":121},[107,929,930],{"class":109,"line":245},[107,931,129],{"emptyLinePlaceholder":128},[107,933,934,936,938,940],{"class":109,"line":254},[107,935,387],{"class":117},[107,937,114],{"class":113},[107,939,118],{"class":117},[107,941,942],{"class":121},"Set up PHP\n",[107,944,945,947,949],{"class":109,"line":263},[107,946,400],{"class":113},[107,948,118],{"class":117},[107,950,951],{"class":121},"shivammathur/setup-php@v2\n",[107,953,954,956],{"class":109,"line":273},[107,955,465],{"class":113},[107,957,139],{"class":117},[107,959,960,963,965],{"class":109,"line":281},[107,961,962],{"class":113},"          php-version",[107,964,118],{"class":117},[107,966,967],{"class":121},"'8.3'\n",[107,969,970,973,975],{"class":109,"line":290},[107,971,972],{"class":113},"          coverage",[107,974,118],{"class":117},[107,976,977],{"class":121},"none\n",[107,979,980],{"class":109,"line":299},[107,981,129],{"emptyLinePlaceholder":128},[107,983,984,986,988,990],{"class":109,"line":309},[107,985,387],{"class":117},[107,987,114],{"class":113},[107,989,118],{"class":117},[107,991,992],{"class":121},"Install dependencies\n",[107,994,995,998,1000],{"class":109,"line":314},[107,996,997],{"class":113},"        run",[107,999,118],{"class":117},[107,1001,1002],{"class":121},"composer install --prefer-dist --no-progress\n",[107,1004,1005],{"class":109,"line":322},[107,1006,129],{"emptyLinePlaceholder":128},[107,1008,1009,1011,1013,1015],{"class":109,"line":330},[107,1010,387],{"class":117},[107,1012,114],{"class":113},[107,1014,118],{"class":117},[107,1016,1017],{"class":121},"Run tests\n",[107,1019,1020,1022,1024],{"class":109,"line":341},[107,1021,997],{"class":113},[107,1023,118],{"class":117},[107,1025,1026],{"class":121},"php artisan test\n",[107,1028,1029],{"class":109,"line":349},[107,1030,129],{"emptyLinePlaceholder":128},[107,1032,1033,1035],{"class":109,"line":360},[107,1034,325],{"class":113},[107,1036,139],{"class":117},[107,1038,1039,1042,1044],{"class":109,"line":371},[107,1040,1041],{"class":113},"    needs",[107,1043,118],{"class":117},[107,1045,1046],{"class":121},"test\n",[107,1048,1049,1052,1054],{"class":109,"line":376},[107,1050,1051],{"class":113},"    if",[107,1053,118],{"class":117},[107,1055,1056],{"class":121},"github.event_name == 'push'\n",[107,1058,1059,1062,1064],{"class":109,"line":384},[107,1060,1061],{"class":113},"    uses",[107,1063,118],{"class":117},[107,1065,1066],{"class":121},"hazelbag/gha-workflows/.github/workflows/build-and-push.yml@main\n",[107,1068,1069,1072],{"class":109,"line":397},[107,1070,1071],{"class":113},"    with",[107,1073,139],{"class":117},[107,1075,1076,1078,1080],{"class":109,"line":408},[107,1077,161],{"class":113},[107,1079,118],{"class":117},[107,1081,1082],{"class":121},"my-laravel-app\n",[107,1084,1085,1087,1089],{"class":109,"line":413},[107,1086,202],{"class":113},[107,1088,118],{"class":117},[107,1090,1091],{"class":121},"./docker/Dockerfile\n",[107,1093,1094,1097,1099],{"class":109,"line":425},[107,1095,1096],{"class":113},"    secrets",[107,1098,118],{"class":117},[107,1100,1101],{"class":121},"inherit\n",[107,1103,1104],{"class":109,"line":435},[107,1105,129],{"emptyLinePlaceholder":128},[107,1107,1108,1111],{"class":109,"line":440},[107,1109,1110],{"class":113},"  deploy-staging",[107,1112,139],{"class":117},[107,1114,1115,1117,1119],{"class":109,"line":452},[107,1116,1041],{"class":113},[107,1118,118],{"class":117},[107,1120,1121],{"class":121},"build\n",[107,1123,1124,1126,1128],{"class":109,"line":462},[107,1125,1051],{"class":113},[107,1127,118],{"class":117},[107,1129,1130],{"class":121},"github.ref == 'refs/heads/develop'\n",[107,1132,1133,1135,1137],{"class":109,"line":470},[107,1134,333],{"class":113},[107,1136,118],{"class":117},[107,1138,338],{"class":121},[107,1140,1141,1143],{"class":109,"line":481},[107,1142,379],{"class":113},[107,1144,139],{"class":117},[107,1146,1147,1149,1151,1153],{"class":109,"line":492},[107,1148,387],{"class":117},[107,1150,114],{"class":113},[107,1152,118],{"class":117},[107,1154,1155],{"class":121},"Trigger Coolify deploy\n",[107,1157,1158,1160,1162],{"class":109,"line":503},[107,1159,997],{"class":113},[107,1161,118],{"class":117},[107,1163,568],{"class":567},[107,1165,1166],{"class":109,"line":508},[107,1167,1168],{"class":121},"          curl -X POST \"${{ secrets.COOLIFY_STAGING_WEBHOOK }}\" \\\n",[107,1170,1171],{"class":109,"line":520},[107,1172,1173],{"class":121},"            -H \"Authorization: Bearer ${{ secrets.COOLIFY_TOKEN }}\"\n",[107,1175,1176],{"class":109,"line":531},[107,1177,129],{"emptyLinePlaceholder":128},[107,1179,1180,1183],{"class":109,"line":541},[107,1181,1182],{"class":113},"  deploy-production",[107,1184,139],{"class":117},[107,1186,1187,1189,1191],{"class":109,"line":548},[107,1188,1041],{"class":113},[107,1190,118],{"class":117},[107,1192,1121],{"class":121},[107,1194,1195,1197,1199],{"class":109,"line":559},[107,1196,1051],{"class":113},[107,1198,118],{"class":117},[107,1200,1201],{"class":121},"startsWith(github.ref, 'refs/tags/v')\n",[107,1203,1204,1206,1208],{"class":109,"line":571},[107,1205,333],{"class":113},[107,1207,118],{"class":117},[107,1209,338],{"class":121},[107,1211,1212,1215,1217,1220],{"class":109,"line":577},[107,1213,1214],{"class":113},"    environment",[107,1216,118],{"class":117},[107,1218,1219],{"class":121},"production",[107,1221,1223],{"class":1222},"sAwPA","  # requires manual approval via GitHub environments\n",[107,1225,1226,1228],{"class":109,"line":583},[107,1227,379],{"class":113},[107,1229,139],{"class":117},[107,1231,1232,1234,1236,1238],{"class":109,"line":589},[107,1233,387],{"class":117},[107,1235,114],{"class":113},[107,1237,118],{"class":117},[107,1239,1240],{"class":121},"Trigger production deploy\n",[107,1242,1243,1245,1247],{"class":109,"line":595},[107,1244,997],{"class":113},[107,1246,118],{"class":117},[107,1248,568],{"class":567},[107,1250,1251],{"class":109,"line":601},[107,1252,1253],{"class":121},"          curl -X POST \"${{ secrets.DEPLOY_PROD_WEBHOOK }}\" \\\n",[107,1255,1256],{"class":109,"line":607},[107,1257,1258],{"class":121},"            -H \"Authorization: Bearer ${{ secrets.DEPLOY_PROD_TOKEN }}\"\n",[11,1260,1261],{},"Boiling it down:",[1263,1264,1265,1268,1271,1276],"ul",{},[29,1266,1267],{},"Tests run on every push and PR",[29,1269,1270],{},"Builds happen only on push (not PRs, to save time and registry space)",[29,1272,1273,1274],{},"Staging deploys on every push to ",[43,1275,45],{},[29,1277,1278],{},"Production deploys only on tag push (and via GitHub's \"environment\" protection, require a manual approval click)",[18,1280,1282],{"id":1281},"the-laravel-dockerfile","The Laravel Dockerfile",[11,1284,1285,1286,1289],{},"The stack-specific piece. This lives at ",[43,1287,1288],{},"docker/Dockerfile"," in each Laravel project:",[70,1291,1295],{"className":1292,"code":1293,"language":1294,"meta":78,"style":78},"language-dockerfile shiki shiki-themes github-dark","# syntax=docker/dockerfile:1.6\n\n# ---- Stage 1: Composer dependencies ----\nFROM composer:2 AS composer-deps\n\nWORKDIR /app\nCOPY composer.json composer.lock ./\nRUN composer install --no-dev --no-scripts --no-autoloader --prefer-dist\n\n# ---- Stage 2: Node build ----\nFROM node:20-alpine AS node-build\n\nWORKDIR /app\nCOPY package.json package-lock.json ./\nRUN npm ci\n\nCOPY resources/ ./resources/\nCOPY vite.config.js ./\nCOPY tailwind.config.js ./\nCOPY postcss.config.js ./\nRUN npm run build\n\n# ---- Stage 3: Production runtime ----\nFROM php:8.3-fpm-alpine AS runtime\n\nRUN apk add --no-cache \\\n    nginx \\\n    supervisor \\\n    postgresql-dev \\\n    libzip-dev \\\n    && docker-php-ext-install pdo pdo_pgsql zip opcache\n\n# PHP config\nCOPY docker/php.ini /usr/local/etc/php/conf.d/app.ini\nCOPY docker/php-fpm.conf /usr/local/etc/php-fpm.d/www.conf\n\n# Nginx config\nCOPY docker/nginx.conf /etc/nginx/nginx.conf\n\n# Supervisor config (runs nginx + php-fpm + horizon)\nCOPY docker/supervisord.conf /etc/supervisor/conf.d/supervisord.conf\n\nWORKDIR /var/www/html\n\n# Copy application\nCOPY --from=composer-deps /app/vendor/ ./vendor/\nCOPY --from=node-build /app/public/build/ ./public/build/\nCOPY . .\n\n# Finish composer autoload (now that app code is present)\nRUN composer dump-autoload --optimize --no-dev\n\n# Laravel optimisations\nRUN php artisan config:cache \\\n    && php artisan route:cache \\\n    && php artisan view:cache\n\n# Permissions\nRUN chown -R www-data:www-data /var/www/html \\\n    && chmod -R 775 storage bootstrap/cache\n\nEXPOSE 80\n\nCMD [\"/usr/bin/supervisord\", \"-c\", \"/etc/supervisor/conf.d/supervisord.conf\"]\n","dockerfile",[43,1296,1297,1302,1306,1311,1325,1329,1337,1345,1353,1357,1362,1374,1378,1384,1391,1398,1402,1409,1416,1423,1430,1437,1441,1446,1458,1462,1469,1474,1479,1484,1489,1494,1498,1503,1510,1517,1521,1526,1533,1537,1542,1549,1553,1560,1564,1569,1576,1583,1590,1594,1599,1606,1610,1615,1622,1627,1632,1636,1641,1648,1653,1657,1665,1669],{"__ignoreMap":78},[107,1298,1299],{"class":109,"line":110},[107,1300,1301],{"class":1222},"# syntax=docker/dockerfile:1.6\n",[107,1303,1304],{"class":109,"line":125},[107,1305,129],{"emptyLinePlaceholder":128},[107,1307,1308],{"class":109,"line":132},[107,1309,1310],{"class":1222},"# ---- Stage 1: Composer dependencies ----\n",[107,1312,1313,1316,1319,1322],{"class":109,"line":142},[107,1314,1315],{"class":567},"FROM",[107,1317,1318],{"class":117}," composer:2 ",[107,1320,1321],{"class":567},"AS",[107,1323,1324],{"class":117}," composer-deps\n",[107,1326,1327],{"class":109,"line":150},[107,1328,129],{"emptyLinePlaceholder":128},[107,1330,1331,1334],{"class":109,"line":158},[107,1332,1333],{"class":567},"WORKDIR",[107,1335,1336],{"class":117}," /app\n",[107,1338,1339,1342],{"class":109,"line":166},[107,1340,1341],{"class":567},"COPY",[107,1343,1344],{"class":117}," composer.json composer.lock ./\n",[107,1346,1347,1350],{"class":109,"line":177},[107,1348,1349],{"class":567},"RUN",[107,1351,1352],{"class":117}," composer install --no-dev --no-scripts --no-autoloader --prefer-dist\n",[107,1354,1355],{"class":109,"line":188},[107,1356,129],{"emptyLinePlaceholder":128},[107,1358,1359],{"class":109,"line":199},[107,1360,1361],{"class":1222},"# ---- Stage 2: Node build ----\n",[107,1363,1364,1366,1369,1371],{"class":109,"line":207},[107,1365,1315],{"class":567},[107,1367,1368],{"class":117}," node:20-alpine ",[107,1370,1321],{"class":567},[107,1372,1373],{"class":117}," node-build\n",[107,1375,1376],{"class":109,"line":217},[107,1377,129],{"emptyLinePlaceholder":128},[107,1379,1380,1382],{"class":109,"line":226},[107,1381,1333],{"class":567},[107,1383,1336],{"class":117},[107,1385,1386,1388],{"class":109,"line":237},[107,1387,1341],{"class":567},[107,1389,1390],{"class":117}," package.json package-lock.json ./\n",[107,1392,1393,1395],{"class":109,"line":245},[107,1394,1349],{"class":567},[107,1396,1397],{"class":117}," npm ci\n",[107,1399,1400],{"class":109,"line":254},[107,1401,129],{"emptyLinePlaceholder":128},[107,1403,1404,1406],{"class":109,"line":263},[107,1405,1341],{"class":567},[107,1407,1408],{"class":117}," resources/ ./resources/\n",[107,1410,1411,1413],{"class":109,"line":273},[107,1412,1341],{"class":567},[107,1414,1415],{"class":117}," vite.config.js ./\n",[107,1417,1418,1420],{"class":109,"line":281},[107,1419,1341],{"class":567},[107,1421,1422],{"class":117}," tailwind.config.js ./\n",[107,1424,1425,1427],{"class":109,"line":290},[107,1426,1341],{"class":567},[107,1428,1429],{"class":117}," postcss.config.js ./\n",[107,1431,1432,1434],{"class":109,"line":299},[107,1433,1349],{"class":567},[107,1435,1436],{"class":117}," npm run build\n",[107,1438,1439],{"class":109,"line":309},[107,1440,129],{"emptyLinePlaceholder":128},[107,1442,1443],{"class":109,"line":314},[107,1444,1445],{"class":1222},"# ---- Stage 3: Production runtime ----\n",[107,1447,1448,1450,1453,1455],{"class":109,"line":322},[107,1449,1315],{"class":567},[107,1451,1452],{"class":117}," php:8.3-fpm-alpine ",[107,1454,1321],{"class":567},[107,1456,1457],{"class":117}," runtime\n",[107,1459,1460],{"class":109,"line":330},[107,1461,129],{"emptyLinePlaceholder":128},[107,1463,1464,1466],{"class":109,"line":341},[107,1465,1349],{"class":567},[107,1467,1468],{"class":117}," apk add --no-cache \\\n",[107,1470,1471],{"class":109,"line":349},[107,1472,1473],{"class":117},"    nginx \\\n",[107,1475,1476],{"class":109,"line":360},[107,1477,1478],{"class":117},"    supervisor \\\n",[107,1480,1481],{"class":109,"line":371},[107,1482,1483],{"class":117},"    postgresql-dev \\\n",[107,1485,1486],{"class":109,"line":376},[107,1487,1488],{"class":117},"    libzip-dev \\\n",[107,1490,1491],{"class":109,"line":384},[107,1492,1493],{"class":117},"    && docker-php-ext-install pdo pdo_pgsql zip opcache\n",[107,1495,1496],{"class":109,"line":397},[107,1497,129],{"emptyLinePlaceholder":128},[107,1499,1500],{"class":109,"line":408},[107,1501,1502],{"class":1222},"# PHP config\n",[107,1504,1505,1507],{"class":109,"line":413},[107,1506,1341],{"class":567},[107,1508,1509],{"class":117}," docker/php.ini /usr/local/etc/php/conf.d/app.ini\n",[107,1511,1512,1514],{"class":109,"line":425},[107,1513,1341],{"class":567},[107,1515,1516],{"class":117}," docker/php-fpm.conf /usr/local/etc/php-fpm.d/www.conf\n",[107,1518,1519],{"class":109,"line":435},[107,1520,129],{"emptyLinePlaceholder":128},[107,1522,1523],{"class":109,"line":440},[107,1524,1525],{"class":1222},"# Nginx config\n",[107,1527,1528,1530],{"class":109,"line":452},[107,1529,1341],{"class":567},[107,1531,1532],{"class":117}," docker/nginx.conf /etc/nginx/nginx.conf\n",[107,1534,1535],{"class":109,"line":462},[107,1536,129],{"emptyLinePlaceholder":128},[107,1538,1539],{"class":109,"line":470},[107,1540,1541],{"class":1222},"# Supervisor config (runs nginx + php-fpm + horizon)\n",[107,1543,1544,1546],{"class":109,"line":481},[107,1545,1341],{"class":567},[107,1547,1548],{"class":117}," docker/supervisord.conf /etc/supervisor/conf.d/supervisord.conf\n",[107,1550,1551],{"class":109,"line":492},[107,1552,129],{"emptyLinePlaceholder":128},[107,1554,1555,1557],{"class":109,"line":503},[107,1556,1333],{"class":567},[107,1558,1559],{"class":117}," /var/www/html\n",[107,1561,1562],{"class":109,"line":508},[107,1563,129],{"emptyLinePlaceholder":128},[107,1565,1566],{"class":109,"line":520},[107,1567,1568],{"class":1222},"# Copy application\n",[107,1570,1571,1573],{"class":109,"line":531},[107,1572,1341],{"class":567},[107,1574,1575],{"class":117}," --from=composer-deps /app/vendor/ ./vendor/\n",[107,1577,1578,1580],{"class":109,"line":541},[107,1579,1341],{"class":567},[107,1581,1582],{"class":117}," --from=node-build /app/public/build/ ./public/build/\n",[107,1584,1585,1587],{"class":109,"line":548},[107,1586,1341],{"class":567},[107,1588,1589],{"class":117}," . .\n",[107,1591,1592],{"class":109,"line":559},[107,1593,129],{"emptyLinePlaceholder":128},[107,1595,1596],{"class":109,"line":571},[107,1597,1598],{"class":1222},"# Finish composer autoload (now that app code is present)\n",[107,1600,1601,1603],{"class":109,"line":577},[107,1602,1349],{"class":567},[107,1604,1605],{"class":117}," composer dump-autoload --optimize --no-dev\n",[107,1607,1608],{"class":109,"line":583},[107,1609,129],{"emptyLinePlaceholder":128},[107,1611,1612],{"class":109,"line":589},[107,1613,1614],{"class":1222},"# Laravel optimisations\n",[107,1616,1617,1619],{"class":109,"line":595},[107,1618,1349],{"class":567},[107,1620,1621],{"class":117}," php artisan config:cache \\\n",[107,1623,1624],{"class":109,"line":601},[107,1625,1626],{"class":117},"    && php artisan route:cache \\\n",[107,1628,1629],{"class":109,"line":607},[107,1630,1631],{"class":117},"    && php artisan view:cache\n",[107,1633,1634],{"class":109,"line":612},[107,1635,129],{"emptyLinePlaceholder":128},[107,1637,1638],{"class":109,"line":624},[107,1639,1640],{"class":1222},"# Permissions\n",[107,1642,1643,1645],{"class":109,"line":634},[107,1644,1349],{"class":567},[107,1646,1647],{"class":117}," chown -R www-data:www-data /var/www/html \\\n",[107,1649,1650],{"class":109,"line":641},[107,1651,1652],{"class":117},"    && chmod -R 775 storage bootstrap/cache\n",[107,1654,1655],{"class":109,"line":652},[107,1656,129],{"emptyLinePlaceholder":128},[107,1658,1659,1662],{"class":109,"line":663},[107,1660,1661],{"class":567},"EXPOSE",[107,1663,1664],{"class":117}," 80\n",[107,1666,1667],{"class":109,"line":674},[107,1668,129],{"emptyLinePlaceholder":128},[107,1670,1671,1674,1677,1680,1682,1685,1687,1690],{"class":109,"line":684},[107,1672,1673],{"class":567},"CMD",[107,1675,1676],{"class":117}," [",[107,1678,1679],{"class":121},"\"/usr/bin/supervisord\"",[107,1681,762],{"class":117},[107,1683,1684],{"class":121},"\"-c\"",[107,1686,762],{"class":117},[107,1688,1689],{"class":121},"\"/etc/supervisor/conf.d/supervisord.conf\"",[107,1691,852],{"class":117},[11,1693,1694],{},"Three stages. The first two produce artifacts (composer vendor dir, compiled frontend assets). The third is the runtime image — minimal, production-focused, includes nginx, PHP-FPM, and supervisor to run them both.",[11,1696,1697],{},"The multi-stage build matters because the final image size comes in around 180MB instead of the 600MB+ you'd get from a single-stage build that drags Composer, Node, and build tooling along for the ride.",[18,1699,1701],{"id":1700},"what-to-do-about-migrations","What to do about migrations",[11,1703,1704],{},"The tricky Laravel-in-Docker question: when do migrations run? Not at image build time (the DB isn't available). Not on every container startup (race conditions if you're scaling horizontally).",[11,1706,1707],{},"My current answer: a dedicated init container that runs once per deploy.",[70,1709,1711],{"className":101,"code":1710,"language":103,"meta":78,"style":78},"# docker-compose.yml (or equivalent ECS task definition)\nservices:\n  migrate:\n    image: ghcr.io/hazelbag/my-laravel-app:latest\n    command: php artisan migrate --force\n    env_file: .env\n    restart: \"no\"\n    depends_on:\n      - db\n\n  app:\n    image: ghcr.io/hazelbag/my-laravel-app:latest\n    depends_on:\n      - migrate\n    restart: unless-stopped\n    # ... rest of app config\n",[43,1712,1713,1718,1725,1732,1742,1752,1762,1772,1779,1786,1790,1797,1805,1811,1818,1827],{"__ignoreMap":78},[107,1714,1715],{"class":109,"line":110},[107,1716,1717],{"class":1222},"# docker-compose.yml (or equivalent ECS task definition)\n",[107,1719,1720,1723],{"class":109,"line":125},[107,1721,1722],{"class":113},"services",[107,1724,139],{"class":117},[107,1726,1727,1730],{"class":109,"line":132},[107,1728,1729],{"class":113},"  migrate",[107,1731,139],{"class":117},[107,1733,1734,1737,1739],{"class":109,"line":142},[107,1735,1736],{"class":113},"    image",[107,1738,118],{"class":117},[107,1740,1741],{"class":121},"ghcr.io/hazelbag/my-laravel-app:latest\n",[107,1743,1744,1747,1749],{"class":109,"line":150},[107,1745,1746],{"class":113},"    command",[107,1748,118],{"class":117},[107,1750,1751],{"class":121},"php artisan migrate --force\n",[107,1753,1754,1757,1759],{"class":109,"line":158},[107,1755,1756],{"class":113},"    env_file",[107,1758,118],{"class":117},[107,1760,1761],{"class":121},".env\n",[107,1763,1764,1767,1769],{"class":109,"line":166},[107,1765,1766],{"class":113},"    restart",[107,1768,118],{"class":117},[107,1770,1771],{"class":121},"\"no\"\n",[107,1773,1774,1777],{"class":109,"line":177},[107,1775,1776],{"class":113},"    depends_on",[107,1778,139],{"class":117},[107,1780,1781,1783],{"class":109,"line":188},[107,1782,387],{"class":117},[107,1784,1785],{"class":121},"db\n",[107,1787,1788],{"class":109,"line":199},[107,1789,129],{"emptyLinePlaceholder":128},[107,1791,1792,1795],{"class":109,"line":207},[107,1793,1794],{"class":113},"  app",[107,1796,139],{"class":117},[107,1798,1799,1801,1803],{"class":109,"line":217},[107,1800,1736],{"class":113},[107,1802,118],{"class":117},[107,1804,1741],{"class":121},[107,1806,1807,1809],{"class":109,"line":226},[107,1808,1776],{"class":113},[107,1810,139],{"class":117},[107,1812,1813,1815],{"class":109,"line":237},[107,1814,387],{"class":117},[107,1816,1817],{"class":121},"migrate\n",[107,1819,1820,1822,1824],{"class":109,"line":245},[107,1821,1766],{"class":113},[107,1823,118],{"class":117},[107,1825,1826],{"class":121},"unless-stopped\n",[107,1828,1829],{"class":109,"line":254},[107,1830,1831],{"class":1222},"    # ... rest of app config\n",[11,1833,1834,1835,1838,1839,1842],{},"The ",[43,1836,1837],{},"migrate"," service runs the migration command once, exits, and the ",[43,1840,1841],{},"app"," service starts only after it succeeds. If the migration fails, the deploy fails loudly before any user traffic hits the new version.",[18,1844,1846],{"id":1845},"the-secrets-question","The secrets question",[11,1848,1849],{},"One thing I deliberately don't do: bake secrets into the image. Every image I push to GHCR is configuration-free. All env-specific values (DB credentials, API keys, app URL) come from the runtime environment.",[11,1851,1852],{},"This means:",[1263,1854,1855,1858,1861],{},[29,1856,1857],{},"The same image runs in staging and production",[29,1859,1860],{},"Rolling back is just pointing the environment at an older image tag",[29,1862,1863],{},"GHCR never holds anything sensitive",[11,1865,1866],{},"Secrets live in:",[1263,1868,1869,1872],{},[29,1870,1871],{},"GitHub Actions secrets (for the deploy webhook trigger)",[29,1873,1874],{},"The deploy target's own secret store (Coolify's env panel, AWS Secrets Manager, Doppler, etc.)",[11,1876,1877],{},"Never in the image. Never in the repo.",[18,1879,1881],{"id":1880},"what-id-tell-past-me","What I'd tell past-me",[11,1883,1884],{},"A few lessons from getting this wrong a few times:",[26,1886,1887,1904,1910,1916],{},[29,1888,1889,1895,1896,1899,1900,1903],{},[32,1890,1891,1892,1894],{},"Pin your base images to specific tags, not ",[43,1893,765],{},"."," ",[43,1897,1898],{},"php:8.3-fpm-alpine"," is fine. ",[43,1901,1902],{},"php:latest"," is a reproducibility bug waiting to happen.",[29,1905,1906,1909],{},[32,1907,1908],{},"Run one process per container."," Supervisor is tempting, but ECS, Kubernetes, and most orchestrators prefer one-process containers. My Laravel image uses supervisor for local dev convenience; in prod I sometimes split nginx into its own container.",[29,1911,1912,1915],{},[32,1913,1914],{},"Cache, cache, cache."," The GHA cache config above probably saves me an hour of build time per week across all projects.",[29,1917,1918,1921],{},[32,1919,1920],{},"Keep the workflow file dumb."," Any time the workflow needs to know about the stack (PHP version, Node version), move that knowledge into the Dockerfile or a project-level config, not into the CI YAML.",[18,1923,1925],{"id":1924},"the-takeaway","The takeaway",[11,1927,1928,1929,1933],{},"A stack-agnostic pipeline is not harder than a stack-specific one. It's arguably simpler, because the mental model is the same for every project: ",[1930,1931,1932],"em",{},"build image, push image, deploy image",". What changes between a Laravel app and a Next.js app is the Dockerfile — and that's where it belongs.",[11,1935,1936],{},"If you're currently juggling three different deploy pipelines across your projects, consolidating to one Docker-based pattern is one of those investments that pays off every time you start a new project or come back to an old one.",[1938,1939,1940],"style",{},"html pre.shiki code .s4JwU, html code.shiki .s4JwU{--shiki-default:#85E89D}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}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 pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}",{"title":78,"searchDepth":132,"depth":132,"links":1942},[1943,1944,1945,1946,1947,1948,1949,1950,1951],{"id":20,"depth":125,"text":21},{"id":67,"depth":125,"text":68},{"id":84,"depth":125,"text":85},{"id":795,"depth":125,"text":796},{"id":1281,"depth":125,"text":1282},{"id":1700,"depth":125,"text":1701},{"id":1845,"depth":125,"text":1846},{"id":1880,"depth":125,"text":1881},{"id":1924,"depth":125,"text":1925},"tooling","The reusable GitHub Actions workflow I drop into every Laravel and Next.js project. One per-project Dockerfile, environment-differentiated deploys, no vendor lock-in.",false,"md",{},"https://digifellow.co.za/og/ghcr-pipeline.png","/blog/ghcr-laravel-pipeline","2025-12-18","5",{"title":5,"description":1953},"blog/ghcr-laravel-pipeline",[1964,1965,1966,1967],"docker","github-actions","ci-cd","infrastructure","fbnFQ2W1jsqgZxseN8_ETxJZDz_1pyUjnoddvDjv_TI",[],1776858404567]