[{"data":1,"prerenderedAt":1211},["ShallowReactive",2],{"blog-page-\u002Fen\u002Fblog":3,"blogs-\u002Fen\u002Fblog":15},{"id":4,"title":5,"body":6,"description":7,"extension":8,"links":6,"meta":9,"navigation":10,"path":11,"seo":12,"stem":13,"__hash__":14},"pages\u002Fen\u002Fblog.yml","Laravel, Nuxt and Infrastructure Articles",null,"Practical notes on Laravel, Vue, Nuxt, GitOps, k3s and building SaaS products.","yml",{},true,"\u002Fen\u002Fblog",{"title":5,"description":7},"en\u002Fblog","Oy4pXpQ_S3ZXIQ4vbXP0vO1CjKz6ElcEuoyOerglfAM",[16],{"id":17,"title":18,"author":19,"body":23,"date":1202,"description":1203,"extension":1204,"image":1205,"meta":1206,"minRead":342,"navigation":10,"path":1207,"seo":1208,"stem":1209,"__hash__":1210},"blog\u002Fen\u002Fblog\u002Fdeploying-laravel-with-k3s-and-argocd.md","A Minimal GitOps Architecture for Laravel on k3s",{"name":20,"avatar":21},"YL",{"src":22,"alt":20},"\u002Fprofile.png",{"type":24,"value":25,"toc":1188},"minimark",[26,30,33,64,67,72,83,86,92,95,99,105,108,112,211,214,218,221,227,230,236,239,243,246,351,354,428,439,442,474,477,483,486,490,493,621,624,627,789,792,796,799,805,808,811,891,894,900,903,948,952,955,961,964,989,992,998,1001,1005,1008,1014,1017,1023,1026,1065,1068,1072,1075,1081,1084,1090,1093,1097,1100,1106,1109,1113,1116,1158,1161,1181,1184],[27,28,29],"p",{},"This is a minimal GitOps architecture for running several Laravel applications on a small VPS.",[27,31,32],{},"The target is practical:",[34,35,36,40,43,46,49,52,55,58,61],"ul",{},[37,38,39],"li",{},"one cheap vps",[37,41,42],{},"five Laravel apps",[37,44,45],{},"HTTPS",[37,47,48],{},"Redis",[37,50,51],{},"Postgres",[37,53,54],{},"file and database backups",[37,56,57],{},"uptime monitoring",[37,59,60],{},"rolling deployments",[37,62,63],{},"disaster recovery in less than 30 minutes",[27,65,66],{},"This is not high availability. It is a low-cost, reproducible setup where the recovery story is stronger than the server.",[68,69,71],"h2",{"id":70},"architecture","Architecture",[73,74,80],"pre",{"className":75,"code":77,"language":78,"meta":79},[76],"language-text","                              Internet\n                                  |\n                                  v\n                         +----------------+\n                         | Traefik + TLS  |\n                         +----------------+\n                                  |\n              +-------------------+-------------------+\n              |                   |                   |\n              v                   v                   v\n          Laravel A           Laravel B           Laravel C...\n              |                   |                   |\n      +-------+-------+   +-------+-------+   +-------+-------+\n      | web replicas  |   | web replicas  |   | web replicas  |\n      | queue worker  |   | queue worker  |   | queue worker  |\n      | scheduler job |   | scheduler job |   | scheduler job |\n      +-------+-------+   +-------+-------+   +-------+-------+\n              |                   |                   |\n              +----------+--------+--------+----------+\n                         |                 |\n                         v                 v\n                    +---------+       +----------+\n                    |  Redis  |       | Postgres |\n                    +---------+       +----------+\n                                           |\n                                           v\n                                    S3-compatible backups\n","text","",[81,82,77],"code",{"__ignoreMap":79},[27,84,85],{},"The cluster is small enough to reason about:",[73,87,90],{"className":88,"code":89,"language":78,"meta":79},[76],"system\n  Argo CD\n  cert-manager\n  sealed or external secrets\n  storage\n  backup controller\n\ndata\n  Postgres\n  Redis\n\nmonitoring\n  uptime checks\n\napps\n  app-a\n  app-b\n  app-c\n  app-d\n  app-e\n",[81,91,89],{"__ignoreMap":79},[27,93,94],{},"The rule is simple: shared infrastructure goes in shared namespaces, applications get their own namespaces, and everything important is recreated from Git.",[68,96,98],{"id":97},"gitops-flow","GitOps Flow",[73,100,103],{"className":101,"code":102,"language":78,"meta":79},[76],"Developer\n   |\n   | git push\n   v\nCI pipeline\n   |\n   | build image\n   | push image\n   v\nContainer registry\n   |\n   | update image tag or digest in Git\n   v\nGitOps repository\n   |\n   | Argo CD watches Git\n   v\nk3s cluster\n   |\n   | rolling update\n   | readiness probes gate traffic\n   v\nProduction\n",[81,104,102],{"__ignoreMap":79},[27,106,107],{},"The server should not be configured by hand after bootstrap. Hand changes disappear. Git changes survive.",[68,109,111],{"id":110},"tool-stack","Tool Stack",[113,114,115,128],"table",{},[116,117,118],"thead",{},[119,120,121,125],"tr",{},[122,123,124],"th",{},"Need",[122,126,127],{},"Tool",[129,130,131,140,148,156,164,172,180,187,195,203],"tbody",{},[119,132,133,137],{},[134,135,136],"td",{},"Lightweight Kubernetes",[134,138,139],{},"k3s",[119,141,142,145],{},[134,143,144],{},"GitOps deployment",[134,146,147],{},"Argo CD",[119,149,150,153],{},[134,151,152],{},"Ingress and routing",[134,154,155],{},"Traefik",[119,157,158,161],{},[134,159,160],{},"TLS certificates",[134,162,163],{},"cert-manager",[119,165,166,169],{},[134,167,168],{},"Secrets in Git",[134,170,171],{},"Sealed Secrets or External Secrets",[119,173,174,177],{},[134,175,176],{},"Database",[134,178,179],{},"CloudNativePG",[119,181,182,185],{},[134,183,184],{},"Cache and queues",[134,186,48],{},[119,188,189,192],{},[134,190,191],{},"Laravel files",[134,193,194],{},"Longhorn or local persistent volumes",[119,196,197,200],{},[134,198,199],{},"Cluster restore",[134,201,202],{},"Velero",[119,204,205,208],{},[134,206,207],{},"Public checks",[134,209,210],{},"Uptime Kuma",[27,212,213],{},"You can swap tools, but avoid removing the roles. A small platform still needs routing, secrets, storage, backups, and health checks.",[68,215,217],{"id":216},"the-laravel-unit","The Laravel Unit",[27,219,220],{},"Each Laravel app should look like the same small unit:",[73,222,225],{"className":223,"code":224,"language":78,"meta":79},[76],"app namespace\n  Ingress\n    -> Service\n      -> web Deployment, 2 replicas\n\n  queue Deployment, 1+ replicas\n  scheduler CronJob\n  app Secret\n  storage PVC\n  database declaration\n",[81,226,224],{"__ignoreMap":79},[27,228,229],{},"For five apps, repeat the same shape five times.",[73,231,234],{"className":232,"code":233,"language":78,"meta":79},[76],"apps\n  app-a  -> web + queue + scheduler + files + db\n  app-b  -> web + queue + scheduler + files + db\n  app-c  -> web + queue + scheduler + files + db\n  app-d  -> web + queue + scheduler + files + db\n  app-e  -> web + queue + scheduler + files + db\n",[81,235,233],{"__ignoreMap":79},[27,237,238],{},"The web process is only one part of Laravel. Treat queues and scheduled commands as first-class workloads.",[68,240,242],{"id":241},"web-deployment","Web Deployment",[27,244,245],{},"Run the web process with two replicas when you want zero-downtime deploys.",[73,247,251],{"className":248,"code":249,"language":250,"meta":79,"style":79},"language-yaml shiki shiki-themes material-theme-lighter material-theme material-theme-palenight","apiVersion: apps\u002Fv1\nkind: Deployment\nspec:\n  replicas: 2\n  strategy:\n    type: RollingUpdate\n    rollingUpdate:\n      maxUnavailable: 0\n      maxSurge: 1\n","yaml",[81,252,253,270,281,290,302,310,321,329,340],{"__ignoreMap":79},[254,255,258,262,266],"span",{"class":256,"line":257},"line",1,[254,259,261],{"class":260},"swJcz","apiVersion",[254,263,265],{"class":264},"sMK4o",":",[254,267,269],{"class":268},"sfazB"," apps\u002Fv1\n",[254,271,273,276,278],{"class":256,"line":272},2,[254,274,275],{"class":260},"kind",[254,277,265],{"class":264},[254,279,280],{"class":268}," Deployment\n",[254,282,284,287],{"class":256,"line":283},3,[254,285,286],{"class":260},"spec",[254,288,289],{"class":264},":\n",[254,291,293,296,298],{"class":256,"line":292},4,[254,294,295],{"class":260},"  replicas",[254,297,265],{"class":264},[254,299,301],{"class":300},"sbssI"," 2\n",[254,303,305,308],{"class":256,"line":304},5,[254,306,307],{"class":260},"  strategy",[254,309,289],{"class":264},[254,311,313,316,318],{"class":256,"line":312},6,[254,314,315],{"class":260},"    type",[254,317,265],{"class":264},[254,319,320],{"class":268}," RollingUpdate\n",[254,322,324,327],{"class":256,"line":323},7,[254,325,326],{"class":260},"    rollingUpdate",[254,328,289],{"class":264},[254,330,332,335,337],{"class":256,"line":331},8,[254,333,334],{"class":260},"      maxUnavailable",[254,336,265],{"class":264},[254,338,339],{"class":300}," 0\n",[254,341,343,346,348],{"class":256,"line":342},9,[254,344,345],{"class":260},"      maxSurge",[254,347,265],{"class":264},[254,349,350],{"class":300}," 1\n",[27,352,353],{},"Traffic should only reach ready pods.",[73,355,357],{"className":248,"code":356,"language":250,"meta":79,"style":79},"livenessProbe:\n  httpGet:\n    path: \u002Fup\n    port: 8080\n\nreadinessProbe:\n  httpGet:\n    path: \u002Fready\n    port: 8080\n",[81,358,359,366,373,383,393,398,405,411,420],{"__ignoreMap":79},[254,360,361,364],{"class":256,"line":257},[254,362,363],{"class":260},"livenessProbe",[254,365,289],{"class":264},[254,367,368,371],{"class":256,"line":272},[254,369,370],{"class":260},"  httpGet",[254,372,289],{"class":264},[254,374,375,378,380],{"class":256,"line":283},[254,376,377],{"class":260},"    path",[254,379,265],{"class":264},[254,381,382],{"class":268}," \u002Fup\n",[254,384,385,388,390],{"class":256,"line":292},[254,386,387],{"class":260},"    port",[254,389,265],{"class":264},[254,391,392],{"class":300}," 8080\n",[254,394,395],{"class":256,"line":304},[254,396,397],{"emptyLinePlaceholder":10},"\n",[254,399,400,403],{"class":256,"line":312},[254,401,402],{"class":260},"readinessProbe",[254,404,289],{"class":264},[254,406,407,409],{"class":256,"line":323},[254,408,370],{"class":260},[254,410,289],{"class":264},[254,412,413,415,417],{"class":256,"line":331},[254,414,377],{"class":260},[254,416,265],{"class":264},[254,418,419],{"class":268}," \u002Fready\n",[254,421,422,424,426],{"class":256,"line":342},[254,423,387],{"class":260},[254,425,265],{"class":264},[254,427,392],{"class":300},[27,429,430,431,434,435,438],{},"Use ",[81,432,433],{},"\u002Fup"," for \"the process is alive\". Use ",[81,436,437],{},"\u002Fready"," for \"this pod can receive traffic\".",[27,440,441],{},"The app receives its environment from a Kubernetes secret:",[73,443,445],{"className":248,"code":444,"language":250,"meta":79,"style":79},"envFrom:\n  - secretRef:\n      name: app-env\n",[81,446,447,454,464],{"__ignoreMap":79},[254,448,449,452],{"class":256,"line":257},[254,450,451],{"class":260},"envFrom",[254,453,289],{"class":264},[254,455,456,459,462],{"class":256,"line":272},[254,457,458],{"class":264},"  -",[254,460,461],{"class":260}," secretRef",[254,463,289],{"class":264},[254,465,466,469,471],{"class":256,"line":283},[254,467,468],{"class":260},"      name",[254,470,265],{"class":264},[254,472,473],{"class":268}," app-env\n",[27,475,476],{},"The mutable Laravel directory should be a mounted volume:",[73,478,481],{"className":479,"code":480,"language":78,"meta":79},[76],"\u002Fvar\u002Fwww\u002Fhtml\u002Fstorage\u002Fapp\n",[81,482,480],{"__ignoreMap":79},[27,484,485],{},"Everything else should be inside the image.",[68,487,489],{"id":488},"queue-and-scheduler","Queue and Scheduler",[27,491,492],{},"Queues should not run inside the web pod.",[73,494,496],{"className":248,"code":495,"language":250,"meta":79,"style":79},"apiVersion: apps\u002Fv1\nkind: Deployment\nmetadata:\n  name: queue\nspec:\n  replicas: 1\n  template:\n    spec:\n      containers:\n        - name: queue\n          command: [\"php\", \"artisan\", \"horizon\"]\n",[81,497,498,506,514,521,531,537,545,552,559,566,579],{"__ignoreMap":79},[254,499,500,502,504],{"class":256,"line":257},[254,501,261],{"class":260},[254,503,265],{"class":264},[254,505,269],{"class":268},[254,507,508,510,512],{"class":256,"line":272},[254,509,275],{"class":260},[254,511,265],{"class":264},[254,513,280],{"class":268},[254,515,516,519],{"class":256,"line":283},[254,517,518],{"class":260},"metadata",[254,520,289],{"class":264},[254,522,523,526,528],{"class":256,"line":292},[254,524,525],{"class":260},"  name",[254,527,265],{"class":264},[254,529,530],{"class":268}," queue\n",[254,532,533,535],{"class":256,"line":304},[254,534,286],{"class":260},[254,536,289],{"class":264},[254,538,539,541,543],{"class":256,"line":312},[254,540,295],{"class":260},[254,542,265],{"class":264},[254,544,350],{"class":300},[254,546,547,550],{"class":256,"line":323},[254,548,549],{"class":260},"  template",[254,551,289],{"class":264},[254,553,554,557],{"class":256,"line":331},[254,555,556],{"class":260},"    spec",[254,558,289],{"class":264},[254,560,561,564],{"class":256,"line":342},[254,562,563],{"class":260},"      containers",[254,565,289],{"class":264},[254,567,569,572,575,577],{"class":256,"line":568},10,[254,570,571],{"class":264},"        -",[254,573,574],{"class":260}," name",[254,576,265],{"class":264},[254,578,530],{"class":268},[254,580,582,585,587,590,593,596,598,601,604,607,609,611,613,616,618],{"class":256,"line":581},11,[254,583,584],{"class":260},"          command",[254,586,265],{"class":264},[254,588,589],{"class":264}," [",[254,591,592],{"class":264},"\"",[254,594,595],{"class":268},"php",[254,597,592],{"class":264},[254,599,600],{"class":264},",",[254,602,603],{"class":264}," \"",[254,605,606],{"class":268},"artisan",[254,608,592],{"class":264},[254,610,600],{"class":264},[254,612,603],{"class":264},[254,614,615],{"class":268},"horizon",[254,617,592],{"class":264},[254,619,620],{"class":264},"]\n",[27,622,623],{},"If a queue is critical, Redis must be durable or the queue should use a durable backend.",[27,625,626],{},"The scheduler is a Kubernetes CronJob:",[73,628,630],{"className":248,"code":629,"language":250,"meta":79,"style":79},"apiVersion: batch\u002Fv1\nkind: CronJob\nmetadata:\n  name: scheduler\nspec:\n  schedule: \"* * * * *\"\n  concurrencyPolicy: Forbid\n  jobTemplate:\n    spec:\n      template:\n        spec:\n          restartPolicy: OnFailure\n          containers:\n            - name: scheduler\n              command: [\"php\", \"artisan\", \"schedule:run\"]\n",[81,631,632,641,650,656,665,671,686,696,703,709,716,723,734,742,754],{"__ignoreMap":79},[254,633,634,636,638],{"class":256,"line":257},[254,635,261],{"class":260},[254,637,265],{"class":264},[254,639,640],{"class":268}," batch\u002Fv1\n",[254,642,643,645,647],{"class":256,"line":272},[254,644,275],{"class":260},[254,646,265],{"class":264},[254,648,649],{"class":268}," CronJob\n",[254,651,652,654],{"class":256,"line":283},[254,653,518],{"class":260},[254,655,289],{"class":264},[254,657,658,660,662],{"class":256,"line":292},[254,659,525],{"class":260},[254,661,265],{"class":264},[254,663,664],{"class":268}," scheduler\n",[254,666,667,669],{"class":256,"line":304},[254,668,286],{"class":260},[254,670,289],{"class":264},[254,672,673,676,678,680,683],{"class":256,"line":312},[254,674,675],{"class":260},"  schedule",[254,677,265],{"class":264},[254,679,603],{"class":264},[254,681,682],{"class":268},"* * * * *",[254,684,685],{"class":264},"\"\n",[254,687,688,691,693],{"class":256,"line":323},[254,689,690],{"class":260},"  concurrencyPolicy",[254,692,265],{"class":264},[254,694,695],{"class":268}," Forbid\n",[254,697,698,701],{"class":256,"line":331},[254,699,700],{"class":260},"  jobTemplate",[254,702,289],{"class":264},[254,704,705,707],{"class":256,"line":342},[254,706,556],{"class":260},[254,708,289],{"class":264},[254,710,711,714],{"class":256,"line":568},[254,712,713],{"class":260},"      template",[254,715,289],{"class":264},[254,717,718,721],{"class":256,"line":581},[254,719,720],{"class":260},"        spec",[254,722,289],{"class":264},[254,724,726,729,731],{"class":256,"line":725},12,[254,727,728],{"class":260},"          restartPolicy",[254,730,265],{"class":264},[254,732,733],{"class":268}," OnFailure\n",[254,735,737,740],{"class":256,"line":736},13,[254,738,739],{"class":260},"          containers",[254,741,289],{"class":264},[254,743,745,748,750,752],{"class":256,"line":744},14,[254,746,747],{"class":264},"            -",[254,749,574],{"class":260},[254,751,265],{"class":264},[254,753,664],{"class":268},[254,755,757,760,762,764,766,768,770,772,774,776,778,780,782,785,787],{"class":256,"line":756},15,[254,758,759],{"class":260},"              command",[254,761,265],{"class":264},[254,763,589],{"class":264},[254,765,592],{"class":264},[254,767,595],{"class":268},[254,769,592],{"class":264},[254,771,600],{"class":264},[254,773,603],{"class":264},[254,775,606],{"class":268},[254,777,592],{"class":264},[254,779,600],{"class":264},[254,781,603],{"class":264},[254,783,784],{"class":268},"schedule:run",[254,786,592],{"class":264},[254,788,620],{"class":264},[27,790,791],{},"That replaces a permanent cron daemon inside the container.",[68,793,795],{"id":794},"database-and-redis","Database and Redis",[27,797,798],{},"Use one managed Postgres cluster inside Kubernetes and create one database per app.",[73,800,803],{"className":801,"code":802,"language":78,"meta":79},[76],"Postgres cluster\n  app_a database\n  app_b database\n  app_c database\n  app_d database\n  app_e database\n",[81,804,802],{"__ignoreMap":79},[27,806,807],{},"CloudNativePG gives you a Kubernetes-native Postgres controller, declarative databases, backups, and recovery workflows.",[27,809,810],{},"A database declaration can stay tiny:",[73,812,814],{"className":248,"code":813,"language":250,"meta":79,"style":79},"apiVersion: postgresql.cnpg.io\u002Fv1\nkind: Database\nmetadata:\n  name: app-a\nspec:\n  cluster:\n    name: postgres\n  name: app_a\n  owner: app\n",[81,815,816,825,834,840,849,855,862,872,881],{"__ignoreMap":79},[254,817,818,820,822],{"class":256,"line":257},[254,819,261],{"class":260},[254,821,265],{"class":264},[254,823,824],{"class":268}," postgresql.cnpg.io\u002Fv1\n",[254,826,827,829,831],{"class":256,"line":272},[254,828,275],{"class":260},[254,830,265],{"class":264},[254,832,833],{"class":268}," Database\n",[254,835,836,838],{"class":256,"line":283},[254,837,518],{"class":260},[254,839,289],{"class":264},[254,841,842,844,846],{"class":256,"line":292},[254,843,525],{"class":260},[254,845,265],{"class":264},[254,847,848],{"class":268}," app-a\n",[254,850,851,853],{"class":256,"line":304},[254,852,286],{"class":260},[254,854,289],{"class":264},[254,856,857,860],{"class":256,"line":312},[254,858,859],{"class":260},"  cluster",[254,861,289],{"class":264},[254,863,864,867,869],{"class":256,"line":323},[254,865,866],{"class":260},"    name",[254,868,265],{"class":264},[254,870,871],{"class":268}," postgres\n",[254,873,874,876,878],{"class":256,"line":331},[254,875,525],{"class":260},[254,877,265],{"class":264},[254,879,880],{"class":268}," app_a\n",[254,882,883,886,888],{"class":256,"line":342},[254,884,885],{"class":260},"  owner",[254,887,265],{"class":264},[254,889,890],{"class":268}," app\n",[27,892,893],{},"Redis can be shared by the apps:",[73,895,898],{"className":896,"code":897,"language":78,"meta":79},[76],"Redis\n  cache\n  sessions\n  queues\n  horizon\n",[81,899,897],{"__ignoreMap":79},[27,901,902],{},"For cheap setups, Redis is often the least protected service. Be explicit:",[113,904,905,915],{},[116,906,907],{},[119,908,909,912],{},[122,910,911],{},"Redis use",[122,913,914],{},"Persistence required?",[129,916,917,925,933,941],{},[119,918,919,922],{},[134,920,921],{},"cache",[134,923,924],{},"no",[119,926,927,930],{},[134,928,929],{},"disposable sessions",[134,931,932],{},"maybe",[119,934,935,938],{},[134,936,937],{},"queues",[134,939,940],{},"yes, unless losing jobs is acceptable",[119,942,943,946],{},[134,944,945],{},"Horizon metrics",[134,947,924],{},[68,949,951],{"id":950},"zero-downtime-deployments","Zero-Downtime Deployments",[27,953,954],{},"The deployment path should look like this:",[73,956,959],{"className":957,"code":958,"language":78,"meta":79},[76],"old pod ready\nold pod ready\n       |\n       | new image arrives\n       v\nold pod ready\nold pod ready\nnew pod starting\n       |\n       | readiness passes\n       v\nold pod ready\nnew pod ready\nnew pod ready\n       |\n       | old pods terminate\n       v\nnew pod ready\nnew pod ready\n",[81,960,958],{"__ignoreMap":79},[27,962,963],{},"The minimum requirements:",[34,965,966,969,974,977,980,983,986],{},[37,967,968],{},"two web replicas",[37,970,971],{},[81,972,973],{},"maxUnavailable: 0",[37,975,976],{},"a real readiness probe",[37,978,979],{},"enough memory for old and new pods during rollout",[37,981,982],{},"no destructive startup tasks",[37,984,985],{},"migrations run separately",[37,987,988],{},"database migrations are backward-compatible",[27,990,991],{},"The migration rule is the one people skip:",[73,993,996],{"className":994,"code":995,"language":78,"meta":79},[76],"deploy 1: add nullable column or new table\ndeploy 2: write code that uses it\ndeploy 3: remove old column only after old code is gone\n",[81,997,995],{"__ignoreMap":79},[27,999,1000],{},"Do not make the new pod require a schema that breaks the old pod still receiving traffic.",[68,1002,1004],{"id":1003},"backups","Backups",[27,1006,1007],{},"Backups need to cover four different things.",[73,1009,1012],{"className":1010,"code":1011,"language":78,"meta":79},[76],"+-------------------+--------------------------+-------------------+\n| State             | Backup                   | Restore target    |\n+-------------------+--------------------------+-------------------+\n| Postgres data     | database backup + WAL    | Postgres cluster  |\n| Laravel files     | volume backup            | app PVC           |\n| Kubernetes state  | Velero                   | cluster resources |\n| Secrets           | sealed in Git + key copy | app secrets       |\n+-------------------+--------------------------+-------------------+\n",[81,1013,1011],{"__ignoreMap":79},[27,1015,1016],{},"Object storage is the common target:",[73,1018,1021],{"className":1019,"code":1020,"language":78,"meta":79},[76],"k3s cluster\n  Postgres backups ----+\n  volume backups ------+----> S3-compatible bucket\n  Velero backups ------+\n",[81,1022,1020],{"__ignoreMap":79},[27,1024,1025],{},"The daily backup baseline:",[73,1027,1029],{"className":248,"code":1028,"language":250,"meta":79,"style":79},"schedule: \"10 2 * * *\"\nttl: 168h\ndestination: s3:\u002F\u002Fproduction-backups\n",[81,1030,1031,1045,1055],{"__ignoreMap":79},[254,1032,1033,1036,1038,1040,1043],{"class":256,"line":257},[254,1034,1035],{"class":260},"schedule",[254,1037,265],{"class":264},[254,1039,603],{"class":264},[254,1041,1042],{"class":268},"10 2 * * *",[254,1044,685],{"class":264},[254,1046,1047,1050,1052],{"class":256,"line":272},[254,1048,1049],{"class":260},"ttl",[254,1051,265],{"class":264},[254,1053,1054],{"class":268}," 168h\n",[254,1056,1057,1060,1062],{"class":256,"line":283},[254,1058,1059],{"class":260},"destination",[254,1061,265],{"class":264},[254,1063,1064],{"class":268}," s3:\u002F\u002Fproduction-backups\n",[27,1066,1067],{},"Seven days of retention is not magic. It is just a starting point. The important part is that database backups, file backups, and cluster metadata are all covered.",[68,1069,1071],{"id":1070},"disaster-recovery-in-30-minutes","Disaster Recovery in 30 Minutes",[27,1073,1074],{},"Fast recovery comes from reducing decisions during the incident.",[73,1076,1079],{"className":1077,"code":1078,"language":78,"meta":79},[76],"00:00  create a new VPS\n05:00  install k3s\n08:00  install Argo CD\n10:00  restore Git access and secret decryption key\n12:00  sync platform controllers\n15:00  restore Postgres and volumes from S3\n22:00  sync Laravel apps\n25:00  point DNS or floating IP to the new server\n30:00  uptime checks green\n",[81,1080,1078],{"__ignoreMap":79},[27,1082,1083],{},"The recovery diagram:",[73,1085,1088],{"className":1086,"code":1087,"language":78,"meta":79},[76],"Git repository                 S3 backups\n  platform manifests             postgres\n  app manifests                  files\n  sealed secrets                 cluster resources\n        |                              |\n        +--------------+---------------+\n                       |\n                       v\n                  new k3s server\n                       |\n                       v\n                  apps restored\n",[81,1089,1087],{"__ignoreMap":79},[27,1091,1092],{},"The cluster is disposable. Git and backups are not.",[68,1094,1096],{"id":1095},"minimal-monitoring","Minimal Monitoring",[27,1098,1099],{},"Start with checks you will actually react to.",[73,1101,1104],{"className":1102,"code":1103,"language":78,"meta":79},[76],"Uptime Kuma\n  GET https:\u002F\u002Fapp-a.example.com\u002Fup\n  GET https:\u002F\u002Fapp-b.example.com\u002Fup\n  GET https:\u002F\u002Fapp-c.example.com\u002Fup\n  GET https:\u002F\u002Fapp-d.example.com\u002Fup\n  GET https:\u002F\u002Fapp-e.example.com\u002Fup\n\nArgo CD\n  apps are Synced\n  apps are Healthy\n\nDatabase\n  cluster healthy\n  latest backup recent\n\nBackups\n  latest Velero backup completed\n  volume backup target reachable\n\nNode\n  disk not full\n  memory not exhausted\n",[81,1105,1103],{"__ignoreMap":79},[27,1107,1108],{},"Prometheus and Grafana are useful later. For the first version, public uptime checks plus backup freshness already catch the failures that matter most.",[68,1110,1112],{"id":1111},"final-checklist","Final Checklist",[27,1114,1115],{},"For each Laravel app:",[34,1117,1118,1121,1124,1127,1130,1133,1136,1139,1146,1149,1152,1155],{},[37,1119,1120],{},"one namespace",[37,1122,1123],{},"one web deployment with two replicas",[37,1125,1126],{},"one service",[37,1128,1129],{},"one ingress with TLS",[37,1131,1132],{},"one queue deployment if jobs are used",[37,1134,1135],{},"one scheduler CronJob",[37,1137,1138],{},"one environment secret",[37,1140,1141,1142,1145],{},"one PVC for ",[81,1143,1144],{},"storage\u002Fapp"," if files are local",[37,1147,1148],{},"one database",[37,1150,1151],{},"readiness and liveness probes",[37,1153,1154],{},"app included in uptime checks",[37,1156,1157],{},"app state included in backups",[27,1159,1160],{},"For the platform:",[34,1162,1163,1166,1169,1172,1175,1178],{},[37,1164,1165],{},"Argo CD can rebuild the cluster from Git",[37,1167,1168],{},"secrets can be decrypted after disaster recovery",[37,1170,1171],{},"Postgres backups restore successfully",[37,1173,1174],{},"file backups restore successfully",[37,1176,1177],{},"Velero can restore Kubernetes resources",[37,1179,1180],{},"DNS or IP failover is documented",[27,1182,1183],{},"That is the real architecture. Kubernetes is only the runtime. The system works because deployment, backups, and recovery are designed together.",[1185,1186,1187],"style",{},"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 .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);}",{"title":79,"searchDepth":257,"depth":272,"links":1189},[1190,1191,1192,1193,1194,1195,1196,1197,1198,1199,1200,1201],{"id":70,"depth":272,"text":71},{"id":97,"depth":272,"text":98},{"id":110,"depth":272,"text":111},{"id":216,"depth":272,"text":217},{"id":241,"depth":272,"text":242},{"id":488,"depth":272,"text":489},{"id":794,"depth":272,"text":795},{"id":950,"depth":272,"text":951},{"id":1003,"depth":272,"text":1004},{"id":1070,"depth":272,"text":1071},{"id":1095,"depth":272,"text":1096},{"id":1111,"depth":272,"text":1112},"2026-05-11","A compact architecture for running several Laravel applications on one small VPS with k3s, Argo CD, Redis, Postgres, backups, uptime checks, and fast disaster recovery.","md","https:\u002F\u002Fimages.pexels.com\u002Fphotos\u002F11035380\u002Fpexels-photo-11035380.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=1",{},"\u002Fen\u002Fblog\u002Fdeploying-laravel-with-k3s-and-argocd",{"title":18,"description":1203},"en\u002Fblog\u002Fdeploying-laravel-with-k3s-and-argocd","8kpe_EreM2Cnr6NGs-LjTH00Vq5oyTuEE0AyjKBCdZ0",1779043745674]