[{"data":1,"prerenderedAt":3027},["ShallowReactive",2],{"blog-posts":3},[4,166,729,2001,2728],{"id":5,"title":6,"body":7,"description":157,"extension":158,"meta":159,"navigation":161,"path":162,"seo":163,"stem":164,"__hash__":165},"content\u002F2026-04-13-remote-raspberries.md","Remote connection to local network without public gateway",{"type":8,"value":9,"toc":150},"minimark",[10,15,19,23,26,30,33,37,134,137,146],[11,12,14],"h2",{"id":13},"task","Task",[16,17,18],"p",{},"Be able to connect to a remote local network.",[11,20,22],{"id":21},"purpose","Purpose",[16,24,25],{},"For example, to manage remote devices such as routers, WiFi access points, etc.",[11,27,29],{"id":28},"constraints","Constraints",[16,31,32],{},"There is no working host and no public IP.",[11,34,36],{"id":35},"my-solution","My Solution",[38,39,40,44,70,78,113,126],"ol",{},[41,42,43],"li",{},"Buy Raspberry Pi 4.",[41,45,46,47],{},"Prepare the device:",[48,49,50,57,60],"ul",{},[41,51,52,53],{},"Install Raspberry Pi OS Lite. ",[54,55,56],"em",{},"Lite is important to avoid unnecessary load since we don’t need a GUI (X server).",[41,58,59],{},"Make sure the device is connected to Raspberry Pi Connect so it’s always reachable as long as it has internet access.",[41,61,62,63,69],{},"Install Tailscale (",[64,65,66],"a",{"href":66,"rel":67},"https:\u002F\u002Ftailscale.com\u002F",[68],"nofollow",") and connect it to your account.",[41,71,72,73],{},"On-site setup:",[48,74,75],{},[41,76,77],{},"Connect the Raspberry Pi via Ethernet (RJ-45). I chose a wired connection to ensure stability and avoid connectivity loss.",[41,79,80,81,108],{},"The most important part:",[82,83,88],"pre",{"className":84,"code":85,"language":86,"meta":87,"style":87},"language-bash shiki shiki-themes github-light github-dark","tailscale up --advertise-routes=192.168.x.x\u002F24\n","bash","",[89,90,91],"code",{"__ignoreMap":87},[92,93,96,100,104],"span",{"class":94,"line":95},"line",1,[92,97,99],{"class":98},"sScJk","tailscale",[92,101,103],{"class":102},"sZZnC"," up",[92,105,107],{"class":106},"sj4cs"," --advertise-routes=192.168.x.x\u002F24\n",[48,109,110],{},[41,111,112],{},"This shares the local subnet with Tailscale clients, allowing access to devices like routers (e.g., 192.168.1.1) within that network.",[41,114,115,116],{},"In the Tailscale admin panel:",[48,117,118],{},[41,119,120,121,125],{},"Enable the route via ",[122,123,124],"strong",{},"Edit Route Settings → Subnet routes"," to grant access to the subnet.",[41,127,128,129],{},"Voilà!",[48,130,131],{},[41,132,133],{},"You can now connect to the local network where your Raspberry Pi is deployed.",[135,136],"hr",{},[16,138,139,142,145],{},[122,140,141],{},"P.S.",[143,144],"br",{},"\nIt’s not always 100% stable — sometimes devices need to be pinged within the Tailscale network so they can discover each other. But overall, the task is solved.",[147,148,149],"style",{},"html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}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":87,"searchDepth":151,"depth":151,"links":152},2,[153,154,155,156],{"id":13,"depth":151,"text":14},{"id":21,"depth":151,"text":22},{"id":28,"depth":151,"text":29},{"id":35,"depth":151,"text":36},"Raspberry Pi and Tailscale to the rescue!","md",{"date":160},"2026-04-13",true,"\u002F2026-04-13-remote-raspberries",{"title":6,"description":157},"2026-04-13-remote-raspberries","qO1V3O1qYznZWwy3gw4WLAhexJHXSewV8WMCa1gVj4M",{"id":167,"title":168,"body":169,"description":722,"extension":158,"meta":723,"navigation":161,"path":725,"seo":726,"stem":727,"__hash__":728},"content\u002F2025-10-12-laravel-cron-jobs.md","Running Laravel Cron Jobs in Docker with FrankenPHP",{"type":8,"value":170,"toc":715},[171,174,199,206,209,249,256,267,301,311,313,321,330,340,343,349,352,354,358,368,375,482,485,535,550,553,555,559,566,580,690,692,696,703,712],[16,172,173],{},"Yesterday I spent some time digging into how cron jobs work in Laravel. There are two fairly straightforward commands involved:",[82,175,177],{"className":84,"code":176,"language":86,"meta":87,"style":87},"php artisan schedule:run\nphp artisan schedule:work\n",[89,178,179,190],{"__ignoreMap":87},[92,180,181,184,187],{"class":94,"line":95},[92,182,183],{"class":98},"php",[92,185,186],{"class":102}," artisan",[92,188,189],{"class":102}," schedule:run\n",[92,191,192,194,196],{"class":94,"line":151},[92,193,183],{"class":98},[92,195,186],{"class":102},[92,197,198],{"class":102}," schedule:work\n",[16,200,201,202,205],{},"The idea is that the first command should be placed into the ",[122,203,204],{},"actual system crontab"," in whatever environment runs your backend (or any environment that has the required environment variables and access to the database).",[16,207,208],{},"Your crontab entry usually looks something like this:",[82,210,212],{"className":84,"code":211,"language":86,"meta":87,"style":87},"* * * * * cd \u002Fpath-to-your-project && php artisan schedule:run >> \u002Fdev\u002Fnull 2>&1\n",[89,213,214],{"__ignoreMap":87},[92,215,216,220,223,225,227,229,233,235,237,240,243,246],{"class":94,"line":95},[92,217,219],{"class":218},"szBVR","*",[92,221,222],{"class":218}," *",[92,224,222],{"class":218},[92,226,222],{"class":218},[92,228,222],{"class":218},[92,230,232],{"class":231},"sVt8B"," cd \u002Fpath-to-your-project && ",[92,234,183],{"class":98},[92,236,186],{"class":102},[92,238,239],{"class":102}," schedule:run",[92,241,242],{"class":218}," >>",[92,244,245],{"class":102}," \u002Fdev\u002Fnull",[92,247,248],{"class":218}," 2>&1\n",[16,250,251,252,255],{},"This means the command runs ",[122,253,254],{},"every minute",".",[16,257,258,259,262,263,266],{},"The idea behind this approach is to delegate the scheduling logic to ",[122,260,261],{},"PHP and Laravel",". When this command runs, Laravel checks ",[89,264,265],{},"app\u002FConsole\u002FKernel.php"," for registered scheduled tasks. They typically look something like this:",[82,268,271],{"className":269,"code":270,"language":183,"meta":87,"style":87},"language-php shiki shiki-themes github-light github-dark","$schedule->command('emails:send Taylor --force')->daily();\n",[89,272,273],{"__ignoreMap":87},[92,274,275,278,281,284,287,290,293,295,298],{"class":94,"line":95},[92,276,277],{"class":231},"$schedule",[92,279,280],{"class":218},"->",[92,282,283],{"class":98},"command",[92,285,286],{"class":231},"(",[92,288,289],{"class":102},"'emails:send Taylor --force'",[92,291,292],{"class":231},")",[92,294,280],{"class":218},[92,296,297],{"class":98},"daily",[92,299,300],{"class":231},"();\n",[16,302,303,304,307,308,255],{},"In this case, ",[89,305,306],{},"->daily()"," means the command is expected to run ",[122,309,310],{},"once per day",[135,312],{},[11,314,316,317,320],{"id":315},"what-does-schedulework-do","What does ",[89,318,319],{},"schedule:work"," do?",[16,322,323,324,326,327,255],{},"According to the documentation, the second command (",[89,325,319],{},") is intended ",[122,328,329],{},"only for local development",[16,331,332,333,336,337,255],{},"All it really does is run ",[89,334,335],{},"schedule:run"," every minute, but implemented in PHP using an ",[122,338,339],{},"infinite loop",[16,341,342],{},"You can see the original implementation here:",[16,344,345],{},[64,346,347],{"href":347,"rel":348},"https:\u002F\u002Fgithub.com\u002Flaravel\u002Fframework\u002Fpull\u002F34618\u002Ffiles",[68],[16,350,351],{},"Modern Laravel versions have a much more complex implementation, but that initial pull request still gives a very clear understanding of how the mechanism works.",[135,353],{},[11,355,357],{"id":356},"my-setup-docker-frankenphp","My setup: Docker + FrankenPHP",[16,359,360,361,364,365,255],{},"I run my backend application in Docker using ",[122,362,363],{},"docker-compose",".\nThe base image is ",[89,366,367],{},"dunglas\u002Ffrankenphp",[16,369,370,371,374],{},"Here’s how the service is defined in ",[89,372,373],{},"docker-compose.yml",":",[82,376,380],{"className":377,"code":378,"language":379,"meta":87,"style":87},"language-yaml shiki shiki-themes github-light github-dark","backend-cron:\n  restart: unless-stopped\n  build:\n    target: dev\n  volumes:\n    - .:\u002Fapp\n  command: \"frankenphp php-cli artisan schedule:work\"\n  healthcheck:\n    disable: true\n  env_file:\n    .env\n","yaml",[89,381,382,391,402,410,421,429,438,449,457,468,476],{"__ignoreMap":87},[92,383,384,388],{"class":94,"line":95},[92,385,387],{"class":386},"s9eBZ","backend-cron",[92,389,390],{"class":231},":\n",[92,392,393,396,399],{"class":94,"line":151},[92,394,395],{"class":386},"  restart",[92,397,398],{"class":231},": ",[92,400,401],{"class":102},"unless-stopped\n",[92,403,405,408],{"class":94,"line":404},3,[92,406,407],{"class":386},"  build",[92,409,390],{"class":231},[92,411,413,416,418],{"class":94,"line":412},4,[92,414,415],{"class":386},"    target",[92,417,398],{"class":231},[92,419,420],{"class":102},"dev\n",[92,422,424,427],{"class":94,"line":423},5,[92,425,426],{"class":386},"  volumes",[92,428,390],{"class":231},[92,430,432,435],{"class":94,"line":431},6,[92,433,434],{"class":231},"    - ",[92,436,437],{"class":102},".:\u002Fapp\n",[92,439,441,444,446],{"class":94,"line":440},7,[92,442,443],{"class":386},"  command",[92,445,398],{"class":231},[92,447,448],{"class":102},"\"frankenphp php-cli artisan schedule:work\"\n",[92,450,452,455],{"class":94,"line":451},8,[92,453,454],{"class":386},"  healthcheck",[92,456,390],{"class":231},[92,458,460,463,465],{"class":94,"line":459},9,[92,461,462],{"class":386},"    disable",[92,464,398],{"class":231},[92,466,467],{"class":106},"true\n",[92,469,471,474],{"class":94,"line":470},10,[92,472,473],{"class":386},"  env_file",[92,475,390],{"class":231},[92,477,479],{"class":94,"line":478},11,[92,480,481],{"class":102},"    .env\n",[16,483,484],{},"Every minute I was getting the same error:",[82,486,488],{"className":84,"code":487,"language":86,"meta":87,"style":87},"backend-cron-1  | 2025-12-09T14:54:00.043306279Z sh: 1: : Permission denied\nbackend-cron-1  | 2025-12-09T14:55:00.041940906Z sh: 1: : Permission denied\n",[89,489,490,516],{"__ignoreMap":87},[92,491,492,495,498,501,504,507,510,513],{"class":94,"line":95},[92,493,494],{"class":98},"backend-cron-1",[92,496,497],{"class":218},"  |",[92,499,500],{"class":98}," 2025-12-09T14:54:00.043306279Z",[92,502,503],{"class":102}," sh:",[92,505,506],{"class":102}," 1:",[92,508,509],{"class":102}," :",[92,511,512],{"class":102}," Permission",[92,514,515],{"class":102}," denied\n",[92,517,518,520,522,525,527,529,531,533],{"class":94,"line":151},[92,519,494],{"class":98},[92,521,497],{"class":218},[92,523,524],{"class":98}," 2025-12-09T14:55:00.041940906Z",[92,526,503],{"class":102},[92,528,506],{"class":102},[92,530,509],{"class":102},[92,532,512],{"class":102},[92,534,515],{"class":102},[16,536,537,538,541,542,545,546,549],{},"At first, based on past experience, I assumed the script simply ",[122,539,540],{},"didn't have permission to write to the filesystem",". This is a common Laravel issue when running inside Docker, usually related to the ",[89,543,544],{},"storage"," and ",[89,547,548],{},"bootstrap\u002Fcache"," directories.",[16,551,552],{},"But that wasn’t the case here.",[135,554],{},[11,556,558],{"id":557},"the-actual-problem","The actual problem",[16,560,561,562,565],{},"It turned out that ",[122,563,564],{},"FrankenPHP cannot spawn subprocesses"," in this setup for some reason.",[16,567,568,569,572,573,576,577,579],{},"I tried playing around with ",[89,570,571],{},"setcap"," in the Docker image, but eventually gave up and just wrote a simple ",[122,574,575],{},"bash script"," that replicates what ",[89,578,319],{}," does:",[82,581,585],{"className":582,"code":583,"language":584,"meta":87,"style":87},"language-sh shiki shiki-themes github-light github-dark","#!\u002Fbin\u002Fsh\nset -e\n\necho \"Starting schedule loop...\"\n\nwhile true; do\n    # Wait until the start of the next minute\n    sleep $((60 - $(date +%S)))\n\n    # Run the scheduler\n    frankenphp php-cli artisan schedule:run\ndone\n","sh",[89,586,587,593,601,606,614,618,632,637,663,667,672,684],{"__ignoreMap":87},[92,588,589],{"class":94,"line":95},[92,590,592],{"class":591},"sJ8bj","#!\u002Fbin\u002Fsh\n",[92,594,595,598],{"class":94,"line":151},[92,596,597],{"class":106},"set",[92,599,600],{"class":106}," -e\n",[92,602,603],{"class":94,"line":404},[92,604,605],{"emptyLinePlaceholder":161},"\n",[92,607,608,611],{"class":94,"line":412},[92,609,610],{"class":106},"echo",[92,612,613],{"class":102}," \"Starting schedule loop...\"\n",[92,615,616],{"class":94,"line":423},[92,617,605],{"emptyLinePlaceholder":161},[92,619,620,623,626,629],{"class":94,"line":431},[92,621,622],{"class":218},"while",[92,624,625],{"class":106}," true",[92,627,628],{"class":231},"; ",[92,630,631],{"class":218},"do\n",[92,633,634],{"class":94,"line":440},[92,635,636],{"class":591},"    # Wait until the start of the next minute\n",[92,638,639,642,645,648,651,654,657,660],{"class":94,"line":451},[92,640,641],{"class":98},"    sleep",[92,643,644],{"class":231}," $((",[92,646,647],{"class":98},"60",[92,649,650],{"class":102}," -",[92,652,653],{"class":231}," $(",[92,655,656],{"class":98},"date",[92,658,659],{"class":102}," +%S",[92,661,662],{"class":231},")))\n",[92,664,665],{"class":94,"line":459},[92,666,605],{"emptyLinePlaceholder":161},[92,668,669],{"class":94,"line":470},[92,670,671],{"class":591},"    # Run the scheduler\n",[92,673,674,677,680,682],{"class":94,"line":478},[92,675,676],{"class":98},"    frankenphp",[92,678,679],{"class":102}," php-cli",[92,681,186],{"class":102},[92,683,189],{"class":102},[92,685,687],{"class":94,"line":686},12,[92,688,689],{"class":218},"done\n",[135,691],{},[11,693,695],{"id":694},"thoughts-on-frankenphp","Thoughts on FrankenPHP",[16,697,698,699,702],{},"Up until this point, I hadn't experienced any issues with FrankenPHP. It really worked as a ",[122,700,701],{},"drop-in replacement"," for the images I used before.",[16,704,705,706,711],{},"I haven't tried ",[64,707,710],{"href":708,"rel":709},"https:\u002F\u002Ffrankenphp.dev\u002Fdocs\u002Fworker\u002F",[68],"worker mode"," yet mostly because I expect it will require quite a bit of tweaking in the application itself to get everything running properly.",[147,713,714],{},"html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}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 .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .s9eBZ, html code.shiki .s9eBZ{--shiki-default:#22863A;--shiki-dark:#85E89D}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}",{"title":87,"searchDepth":151,"depth":151,"links":716},[717,719,720,721],{"id":315,"depth":151,"text":718},"What does schedule:work do?",{"id":356,"depth":151,"text":357},{"id":557,"depth":151,"text":558},{"id":694,"depth":151,"text":695},"How to run Laravel cron jobs in Docker using FrankenPHP",{"date":724},"2025-10-12","\u002F2025-10-12-laravel-cron-jobs",{"title":168,"description":722},"2025-10-12-laravel-cron-jobs","QcDe0k_7TznpSPnwB5xrDozjRN6G4UhyJvMjbEtpHe8",{"id":730,"title":731,"body":732,"description":1994,"extension":158,"meta":1995,"navigation":161,"path":1997,"seo":1998,"stem":1999,"__hash__":2000},"content\u002F2025-09-11-mysql-to-pg.md","Migrating a Large MySQL Database to PostgreSQL on AWS RDS",{"type":8,"value":733,"toc":1978},[734,738,741,784,788,791,798,817,821,827,830,853,861,875,878,882,889,892,895,1040,1051,1055,1062,1068,1079,1108,1111,1115,1121,1127,1129,1200,1204,1215,1254,1261,1272,1304,1308,1311,1318,1329,1336,1339,1582,1586,1595,1598,1627,1634,1655,1669,1674,1741,1744,1752,1755,1787,1807,1893,1896,1901,1915,1919,1922,1927,1934,1938,1944,1948,1955,1958,1975],[11,735,737],{"id":736},"preconditions","Preconditions",[16,739,740],{},"Before starting the migration, here was the situation:",[48,742,743,754,761,768,774],{},[41,744,745,746,749,750,753],{},"The new PostgreSQL database had to run on ",[122,747,748],{},"AWS RDS"," (so ",[122,751,752],{},"no access to the database host filesystem",").",[41,755,756,757,760],{},"Some tables contained ",[122,758,759],{},"millions of rows"," (~1.5 GB per table).",[41,762,763,764,767],{},"The schema included ",[122,765,766],{},"JSON columns with virtual columns derived from JSON"," (used for indexing).",[41,769,770,771,255],{},"The database used ",[122,772,773],{},"ENUM types",[41,775,776,777,780,781,255],{},"The backend is written in ",[122,778,779],{},"Laravel",", with heavy use of ",[122,782,783],{},"database migrations",[11,785,787],{"id":786},"where-to-start","Where to start?",[16,789,790],{},"The first step was researching standard migration tools.",[16,792,793,794,797],{},"The most popular option is ",[122,795,796],{},"pgloader",". I also found a couple of Node.js-based tools. I ended up discarding both approaches because:",[48,799,800,810],{},[41,801,802,803,806,807,753],{},"Many column types were not mapped correctly (for example, ",[89,804,805],{},"json"," not becoming ",[89,808,809],{},"jsonb",[41,811,812,813,816],{},"The migration process itself was ",[122,814,815],{},"too slow",", which is unacceptable when dealing with production data.",[11,818,820],{"id":819},"schema-first-approach","Schema-first approach",[16,822,823,824,255],{},"I eventually concluded that the ",[122,825,826],{},"PostgreSQL schema should be created manually before migrating the data",[16,828,829],{},"This decision was driven by several factors:",[48,831,832,835,841,847],{},[41,833,834],{},"Type mismatches mentioned earlier",[41,836,837,838],{},"The need to ",[122,839,840],{},"modify the schema during migration",[41,842,843,844],{},"Adding ",[122,845,846],{},"table partitioning",[41,848,849,850],{},"Removing ",[122,851,852],{},"deprecated tables",[16,854,855,856],{},"To achieve this, I generated a Laravel-compatible migration for the entire existing database using ",[64,857,860],{"href":858,"rel":859},"https:\u002F\u002Fgithub.com\u002Fkitloong\u002Flaravel-migrations-generator",[68],"laravel-migrations-generator",[16,862,863,864,867,868,871,872,255],{},"I used the ",[89,865,866],{},"--squash"," flag to produce ",[122,869,870],{},"one large migration file"," and removed all previous migrations. This allowed new environments to start with the ",[122,873,874],{},"current schema from scratch",[16,876,877],{},"Naturally, the generated migration required some adjustments.",[11,879,881],{"id":880},"handling-enum-types","Handling ENUM types",[16,883,884,885,888],{},"Laravel handles enums in PostgreSQL as ",[122,886,887],{},"check constraints",", not as real database types. I wanted proper PostgreSQL enum types.",[16,890,891],{},"To make that work, I extended Laravel’s schema grammar so migrations could reference custom types.",[16,893,894],{},"Example:",[82,896,898],{"className":269,"code":897,"language":183,"meta":87,"style":87},"Grammar::macro('typeCountry', function () {\n    return 'country';\n});\n\nDB::statement(\"CREATE TYPE country AS ENUM('russia', 'china', 'germany');\");\n\nSchema::create('users', function (Blueprint $table) {\n    ...\n    $table->addColumn('country', 'country');\n});\n",[89,899,900,925,936,941,945,978,982,1010,1015,1036],{"__ignoreMap":87},[92,901,902,905,908,911,913,916,919,922],{"class":94,"line":95},[92,903,904],{"class":106},"Grammar",[92,906,907],{"class":218},"::",[92,909,910],{"class":98},"macro",[92,912,286],{"class":231},[92,914,915],{"class":102},"'typeCountry'",[92,917,918],{"class":231},", ",[92,920,921],{"class":218},"function",[92,923,924],{"class":231}," () {\n",[92,926,927,930,933],{"class":94,"line":151},[92,928,929],{"class":218},"    return",[92,931,932],{"class":102}," 'country'",[92,934,935],{"class":231},";\n",[92,937,938],{"class":94,"line":404},[92,939,940],{"class":231},"});\n",[92,942,943],{"class":94,"line":412},[92,944,605],{"emptyLinePlaceholder":161},[92,946,947,950,952,955,957,960,963,966,969,972,975],{"class":94,"line":423},[92,948,949],{"class":106},"DB",[92,951,907],{"class":218},[92,953,954],{"class":98},"statement",[92,956,286],{"class":231},[92,958,959],{"class":102},"\"",[92,961,962],{"class":218},"CREATE",[92,964,965],{"class":218}," TYPE",[92,967,968],{"class":102}," country ",[92,970,971],{"class":218},"AS",[92,973,974],{"class":102}," ENUM('russia', 'china', 'germany');\"",[92,976,977],{"class":231},");\n",[92,979,980],{"class":94,"line":431},[92,981,605],{"emptyLinePlaceholder":161},[92,983,984,987,989,992,994,997,999,1001,1004,1007],{"class":94,"line":440},[92,985,986],{"class":106},"Schema",[92,988,907],{"class":218},[92,990,991],{"class":98},"create",[92,993,286],{"class":231},[92,995,996],{"class":102},"'users'",[92,998,918],{"class":231},[92,1000,921],{"class":218},[92,1002,1003],{"class":231}," (",[92,1005,1006],{"class":106},"Blueprint",[92,1008,1009],{"class":231}," $table) {\n",[92,1011,1012],{"class":94,"line":451},[92,1013,1014],{"class":218},"    ...\n",[92,1016,1017,1020,1022,1025,1027,1030,1032,1034],{"class":94,"line":459},[92,1018,1019],{"class":231},"    $table",[92,1021,280],{"class":218},[92,1023,1024],{"class":98},"addColumn",[92,1026,286],{"class":231},[92,1028,1029],{"class":102},"'country'",[92,1031,918],{"class":231},[92,1033,1029],{"class":102},[92,1035,977],{"class":231},[92,1037,1038],{"class":94,"line":470},[92,1039,940],{"class":231},[16,1041,1042,1043,1046,1047,1050],{},"Without the ",[89,1044,1045],{},"Grammar::macro",", Laravel would not recognize the ",[89,1048,1049],{},"country"," column type.",[11,1052,1054],{"id":1053},"replacing-virtual-json-columns","Replacing virtual JSON columns",[16,1056,1057,1058,1061],{},"MySQL supported ",[122,1059,1060],{},"virtual columns derived from JSON fields",". PostgreSQL does not support virtual columns in the same way.",[16,1063,1064,1065,255],{},"ChatGPT actually suggested a simple alternative: ",[122,1066,1067],{},"use an index on the JSON expression instead",[16,1069,1070,1071,1074,1075,1078],{},"For example, if ",[89,1072,1073],{},"settings"," is a JSON column in the ",[89,1076,1077],{},"users"," table:",[82,1080,1084],{"className":1081,"code":1082,"language":1083,"meta":87,"style":87},"language-sql shiki shiki-themes github-light github-dark","CREATE INDEX ON users((settings->>'setting1'));\n","sql",[89,1085,1086],{"__ignoreMap":87},[92,1087,1088,1090,1093,1096,1099,1102,1105],{"class":94,"line":95},[92,1089,962],{"class":218},[92,1091,1092],{"class":218}," INDEX",[92,1094,1095],{"class":98}," ON",[92,1097,1098],{"class":231}," users((settings",[92,1100,1101],{"class":218},"->>",[92,1103,1104],{"class":102},"'setting1'",[92,1106,1107],{"class":231},"));\n",[16,1109,1110],{},"The only thing to remember is to update the application code to access the value directly from the JSON field rather than the former virtual column.",[11,1112,1114],{"id":1113},"preparing-tables-for-partitioning","Preparing tables for partitioning",[16,1116,1117,1118,255],{},"One of the tables needed to be ",[122,1119,1120],{},"partitioned by date",[16,1122,1123,1124,255],{},"In PostgreSQL, tables must be created ",[122,1125,1126],{},"with partitioning defined from the start",[16,1128,894],{},[82,1130,1132],{"className":1081,"code":1131,"language":1083,"meta":87,"style":87},"CREATE TABLE actions (\n    id SERIAL,\n    ...\n    created_at TIMESTAMP NOT NULL,\n    PRIMARY KEY (id, created_at)\n) PARTITION BY RANGE (created_at);\n",[89,1133,1134,1147,1158,1162,1175,1183],{"__ignoreMap":87},[92,1135,1136,1138,1141,1144],{"class":94,"line":95},[92,1137,962],{"class":218},[92,1139,1140],{"class":218}," TABLE",[92,1142,1143],{"class":98}," actions",[92,1145,1146],{"class":231}," (\n",[92,1148,1149,1152,1155],{"class":94,"line":151},[92,1150,1151],{"class":231},"    id ",[92,1153,1154],{"class":218},"SERIAL",[92,1156,1157],{"class":231},",\n",[92,1159,1160],{"class":94,"line":404},[92,1161,1014],{"class":231},[92,1163,1164,1167,1170,1173],{"class":94,"line":412},[92,1165,1166],{"class":231},"    created_at ",[92,1168,1169],{"class":218},"TIMESTAMP",[92,1171,1172],{"class":218}," NOT NULL",[92,1174,1157],{"class":231},[92,1176,1177,1180],{"class":94,"line":423},[92,1178,1179],{"class":218},"    PRIMARY KEY",[92,1181,1182],{"class":231}," (id, created_at)\n",[92,1184,1185,1188,1191,1194,1197],{"class":94,"line":431},[92,1186,1187],{"class":231},") ",[92,1189,1190],{"class":218},"PARTITION",[92,1192,1193],{"class":218}," BY",[92,1195,1196],{"class":218}," RANGE",[92,1198,1199],{"class":231}," (created_at);\n",[11,1201,1203],{"id":1202},"enabling-required-extensions","Enabling required extensions",[16,1205,1206,1207,1210,1211,1214],{},"One of the main motivations for moving to PostgreSQL was the ability to use ",[122,1208,1209],{},"vector data types",", so I added the ",[89,1212,1213],{},"vector"," extension during migration:",[82,1216,1218],{"className":269,"code":1217,"language":183,"meta":87,"style":87},"DB::statement(\"CREATE EXTENSION IF NOT EXISTS vector;\");\n",[89,1219,1220],{"__ignoreMap":87},[92,1221,1222,1224,1226,1228,1230,1232,1234,1237,1240,1243,1246,1249,1252],{"class":94,"line":95},[92,1223,949],{"class":106},[92,1225,907],{"class":218},[92,1227,954],{"class":98},[92,1229,286],{"class":231},[92,1231,959],{"class":102},[92,1233,962],{"class":218},[92,1235,1236],{"class":102}," EXTENSION ",[92,1238,1239],{"class":218},"IF",[92,1241,1242],{"class":218}," NOT",[92,1244,1245],{"class":218}," EXISTS",[92,1247,1248],{"class":218}," vector",[92,1250,1251],{"class":102},";\"",[92,1253,977],{"class":231},[16,1255,1256,1257,1260],{},"This command must be executed ",[122,1258,1259],{},"for every new database"," where the extension is required.",[16,1262,1263,1264,1267,1268,1271],{},"We also relied on ",[122,1265,1266],{},"geospatial queries",", so we enabled ",[122,1269,1270],{},"PostGIS"," as well:",[82,1273,1275],{"className":269,"code":1274,"language":183,"meta":87,"style":87},"DB::statement(\"CREATE EXTENSION IF NOT EXISTS postgis;\");\n",[89,1276,1277],{"__ignoreMap":87},[92,1278,1279,1281,1283,1285,1287,1289,1291,1293,1295,1297,1299,1302],{"class":94,"line":95},[92,1280,949],{"class":106},[92,1282,907],{"class":218},[92,1284,954],{"class":98},[92,1286,286],{"class":231},[92,1288,959],{"class":102},[92,1290,962],{"class":218},[92,1292,1236],{"class":102},[92,1294,1239],{"class":218},[92,1296,1242],{"class":218},[92,1298,1245],{"class":218},[92,1300,1301],{"class":102}," postgis;\"",[92,1303,977],{"class":231},[11,1305,1307],{"id":1306},"local-development-environment","Local development environment",[16,1309,1310],{},"Speaking of extensions, they must first be installed in the database environment.",[16,1312,1313,1314,1317],{},"AWS RDS comes with many extensions pre-installed. However, for ",[122,1315,1316],{},"local development"," we usually rely on Docker images.",[16,1319,1320,1321,1324,1325,1328],{},"Finding images with ",[122,1322,1323],{},"Postgres + pgvector"," or ",[122,1326,1327],{},"Postgres + PostGIS"," separately is easy.",[16,1330,1331,1332,1335],{},"Finding an image that contains ",[122,1333,1334],{},"both extensions and works on ARM64"," is not.",[16,1337,1338],{},"So I had to build one myself:",[82,1340,1344],{"className":1341,"code":1342,"language":1343,"meta":87,"style":87},"language-dockerfile shiki shiki-themes github-light github-dark","FROM pgvector\u002Fpgvector:0.8.0-pg16\n\nENV POSTGIS_VERSION=3.5.0\n\nRUN apt-get update && apt-get install -y \\\n    build-essential \\\n    cmake \\\n    git \\\n    wget \\\n    pkg-config \\\n    postgresql-server-dev-16 \\\n    libxml2-dev \\\n    libgeos-dev \\\n    libproj-dev \\\n    libgdal-dev \\\n    libjson-c-dev \\\n    libprotobuf-c-dev \\\n    protobuf-c-compiler \\\n    libssl-dev \\\n    libcurl4-openssl-dev \\\n    libtiff-dev \\\n    libsqlite3-dev \\\n    sqlite3 \\\n    && rm -rf \u002Fvar\u002Flib\u002Fapt\u002Flists\u002F*\n\nRUN cd \u002Ftmp && \\\n    wget https:\u002F\u002Fdownload.osgeo.org\u002Fpostgis\u002Fsource\u002Fpostgis-${POSTGIS_VERSION}.tar.gz && \\\n    tar -xzf postgis-${POSTGIS_VERSION}.tar.gz && \\\n    cd postgis-${POSTGIS_VERSION} && \\\n    .\u002Fconfigure \\\n    --with-pgconfig=\u002Fusr\u002Fbin\u002Fpg_config \\\n    --with-geosconfig=\u002Fusr\u002Fbin\u002Fgeos-config \\\n    --with-projdir=\u002Fusr \\\n    --with-gdalconfig=\u002Fusr\u002Fbin\u002Fgdal-config \\\n    --with-jsondir=\u002Fusr \\\n    --with-protobufdir=\u002Fusr && \\\n    make && \\\n    make install && \\\n    cd \u002F && \\\n    rm -rf \u002Ftmp\u002Fpostgis-*\n","dockerfile",[89,1345,1346,1354,1358,1366,1370,1378,1383,1388,1393,1398,1403,1408,1413,1419,1425,1431,1437,1443,1449,1455,1461,1467,1473,1479,1485,1490,1498,1504,1510,1516,1522,1528,1534,1540,1546,1552,1558,1564,1570,1576],{"__ignoreMap":87},[92,1347,1348,1351],{"class":94,"line":95},[92,1349,1350],{"class":218},"FROM",[92,1352,1353],{"class":231}," pgvector\u002Fpgvector:0.8.0-pg16\n",[92,1355,1356],{"class":94,"line":151},[92,1357,605],{"emptyLinePlaceholder":161},[92,1359,1360,1363],{"class":94,"line":404},[92,1361,1362],{"class":218},"ENV",[92,1364,1365],{"class":231}," POSTGIS_VERSION=3.5.0\n",[92,1367,1368],{"class":94,"line":412},[92,1369,605],{"emptyLinePlaceholder":161},[92,1371,1372,1375],{"class":94,"line":423},[92,1373,1374],{"class":218},"RUN",[92,1376,1377],{"class":231}," apt-get update && apt-get install -y \\\n",[92,1379,1380],{"class":94,"line":431},[92,1381,1382],{"class":231},"    build-essential \\\n",[92,1384,1385],{"class":94,"line":440},[92,1386,1387],{"class":231},"    cmake \\\n",[92,1389,1390],{"class":94,"line":451},[92,1391,1392],{"class":231},"    git \\\n",[92,1394,1395],{"class":94,"line":459},[92,1396,1397],{"class":231},"    wget \\\n",[92,1399,1400],{"class":94,"line":470},[92,1401,1402],{"class":231},"    pkg-config \\\n",[92,1404,1405],{"class":94,"line":478},[92,1406,1407],{"class":231},"    postgresql-server-dev-16 \\\n",[92,1409,1410],{"class":94,"line":686},[92,1411,1412],{"class":231},"    libxml2-dev \\\n",[92,1414,1416],{"class":94,"line":1415},13,[92,1417,1418],{"class":231},"    libgeos-dev \\\n",[92,1420,1422],{"class":94,"line":1421},14,[92,1423,1424],{"class":231},"    libproj-dev \\\n",[92,1426,1428],{"class":94,"line":1427},15,[92,1429,1430],{"class":231},"    libgdal-dev \\\n",[92,1432,1434],{"class":94,"line":1433},16,[92,1435,1436],{"class":231},"    libjson-c-dev \\\n",[92,1438,1440],{"class":94,"line":1439},17,[92,1441,1442],{"class":231},"    libprotobuf-c-dev \\\n",[92,1444,1446],{"class":94,"line":1445},18,[92,1447,1448],{"class":231},"    protobuf-c-compiler \\\n",[92,1450,1452],{"class":94,"line":1451},19,[92,1453,1454],{"class":231},"    libssl-dev \\\n",[92,1456,1458],{"class":94,"line":1457},20,[92,1459,1460],{"class":231},"    libcurl4-openssl-dev \\\n",[92,1462,1464],{"class":94,"line":1463},21,[92,1465,1466],{"class":231},"    libtiff-dev \\\n",[92,1468,1470],{"class":94,"line":1469},22,[92,1471,1472],{"class":231},"    libsqlite3-dev \\\n",[92,1474,1476],{"class":94,"line":1475},23,[92,1477,1478],{"class":231},"    sqlite3 \\\n",[92,1480,1482],{"class":94,"line":1481},24,[92,1483,1484],{"class":231},"    && rm -rf \u002Fvar\u002Flib\u002Fapt\u002Flists\u002F*\n",[92,1486,1488],{"class":94,"line":1487},25,[92,1489,605],{"emptyLinePlaceholder":161},[92,1491,1493,1495],{"class":94,"line":1492},26,[92,1494,1374],{"class":218},[92,1496,1497],{"class":231}," cd \u002Ftmp && \\\n",[92,1499,1501],{"class":94,"line":1500},27,[92,1502,1503],{"class":231},"    wget https:\u002F\u002Fdownload.osgeo.org\u002Fpostgis\u002Fsource\u002Fpostgis-${POSTGIS_VERSION}.tar.gz && \\\n",[92,1505,1507],{"class":94,"line":1506},28,[92,1508,1509],{"class":231},"    tar -xzf postgis-${POSTGIS_VERSION}.tar.gz && \\\n",[92,1511,1513],{"class":94,"line":1512},29,[92,1514,1515],{"class":231},"    cd postgis-${POSTGIS_VERSION} && \\\n",[92,1517,1519],{"class":94,"line":1518},30,[92,1520,1521],{"class":231},"    .\u002Fconfigure \\\n",[92,1523,1525],{"class":94,"line":1524},31,[92,1526,1527],{"class":231},"    --with-pgconfig=\u002Fusr\u002Fbin\u002Fpg_config \\\n",[92,1529,1531],{"class":94,"line":1530},32,[92,1532,1533],{"class":231},"    --with-geosconfig=\u002Fusr\u002Fbin\u002Fgeos-config \\\n",[92,1535,1537],{"class":94,"line":1536},33,[92,1538,1539],{"class":231},"    --with-projdir=\u002Fusr \\\n",[92,1541,1543],{"class":94,"line":1542},34,[92,1544,1545],{"class":231},"    --with-gdalconfig=\u002Fusr\u002Fbin\u002Fgdal-config \\\n",[92,1547,1549],{"class":94,"line":1548},35,[92,1550,1551],{"class":231},"    --with-jsondir=\u002Fusr \\\n",[92,1553,1555],{"class":94,"line":1554},36,[92,1556,1557],{"class":231},"    --with-protobufdir=\u002Fusr && \\\n",[92,1559,1561],{"class":94,"line":1560},37,[92,1562,1563],{"class":231},"    make && \\\n",[92,1565,1567],{"class":94,"line":1566},38,[92,1568,1569],{"class":231},"    make install && \\\n",[92,1571,1573],{"class":94,"line":1572},39,[92,1574,1575],{"class":231},"    cd \u002F && \\\n",[92,1577,1579],{"class":94,"line":1578},40,[92,1580,1581],{"class":231},"    rm -rf \u002Ftmp\u002Fpostgis-*\n",[11,1583,1585],{"id":1584},"migration-process","Migration process",[16,1587,1588,1589,545,1592,255],{},"After testing the process locally, I ran it in ",[122,1590,1591],{},"staging",[122,1593,1594],{},"production",[16,1596,1597],{},"The migration flow looked like this:",[38,1599,1600,1606,1612],{},[41,1601,1602,1603],{},"Create a new ",[122,1604,1605],{},"AWS RDS instance",[41,1607,1608,1609],{},"Connect using ",[89,1610,1611],{},"psql",[41,1613,1614,1615],{},"Enable the required extensions:",[48,1616,1617,1622],{},[41,1618,1619],{},[89,1620,1621],{},"aws_commons",[41,1623,1624],{},[89,1625,1626],{},"aws_s3",[16,1628,1629,1630,1633],{},"These allow importing CSV files from ",[122,1631,1632],{},"S3",", since RDS does not provide filesystem access.",[38,1635,1636,1643,1649,1652],{"start":412},[41,1637,1638,1639,1642],{},"Create a ",[122,1640,1641],{},"VPC Endpoint"," so the database can access S3.",[41,1644,1645,1646,292],{},"Update the Laravel database configuration (",[89,1647,1648],{},"config\u002Fdatabase.php",[41,1650,1651],{},"Deploy the code with the new migration and apply the schema.",[41,1653,1654],{},"Stop the application to prevent writes during migration:",[82,1656,1658],{"className":84,"code":1657,"language":86,"meta":87,"style":87},"php artisan down\n",[89,1659,1660],{"__ignoreMap":87},[92,1661,1662,1664,1666],{"class":94,"line":95},[92,1663,183],{"class":98},[92,1665,186],{"class":102},[92,1667,1668],{"class":102}," down\n",[38,1670,1671],{"start":451},[41,1672,1673],{},"Export data from MySQL to CSV:",[82,1675,1677],{"className":84,"code":1676,"language":86,"meta":87,"style":87},"mysqldump --port=3308 -u root -psecret db-name \\\n  --no-create-info \\\n  --fields-terminated-by=, \\\n  --tab=\u002Fvar\u002Flib\u002Fmysql-files \\\n  --fields-enclosed-by='\\\"' \\\n  --lines-terminated-by='\\n'\n",[89,1678,1679,1702,1709,1716,1723,1733],{"__ignoreMap":87},[92,1680,1681,1684,1687,1690,1693,1696,1699],{"class":94,"line":95},[92,1682,1683],{"class":98},"mysqldump",[92,1685,1686],{"class":106}," --port=3308",[92,1688,1689],{"class":106}," -u",[92,1691,1692],{"class":102}," root",[92,1694,1695],{"class":106}," -psecret",[92,1697,1698],{"class":102}," db-name",[92,1700,1701],{"class":106}," \\\n",[92,1703,1704,1707],{"class":94,"line":151},[92,1705,1706],{"class":106},"  --no-create-info",[92,1708,1701],{"class":106},[92,1710,1711,1714],{"class":94,"line":404},[92,1712,1713],{"class":106},"  --fields-terminated-by=,",[92,1715,1701],{"class":106},[92,1717,1718,1721],{"class":94,"line":412},[92,1719,1720],{"class":106},"  --tab=\u002Fvar\u002Flib\u002Fmysql-files",[92,1722,1701],{"class":106},[92,1724,1725,1728,1731],{"class":94,"line":423},[92,1726,1727],{"class":106},"  --fields-enclosed-by=",[92,1729,1730],{"class":102},"'\\\"'",[92,1732,1701],{"class":106},[92,1734,1735,1738],{"class":94,"line":431},[92,1736,1737],{"class":106},"  --lines-terminated-by=",[92,1739,1740],{"class":102},"'\\n'\n",[16,1742,1743],{},"This generates files for each table:",[82,1745,1750],{"className":1746,"code":1748,"language":1749},[1747],"language-text","actions.sql  actions.txt\nusers.sql    users.txt\n","text",[89,1751,1748],{"__ignoreMap":87},[16,1753,1754],{},"Key flags:",[48,1756,1757,1763,1769,1775,1781],{},[41,1758,1759,1762],{},[89,1760,1761],{},"--no-create-info"," → skip schema generation",[41,1764,1765,1768],{},[89,1766,1767],{},"--fields-terminated-by=,"," → comma separator",[41,1770,1771,1774],{},[89,1772,1773],{},"--tab=\u002Fvar\u002Flib\u002Fmysql-files"," → required export directory",[41,1776,1777,1780],{},[89,1778,1779],{},"--fields-enclosed-by='\"'"," → format expected by PostgreSQL COPY",[41,1782,1783,1786],{},[89,1784,1785],{},"--lines-terminated-by='\\n'"," → newline rows",[38,1788,1789,1804],{"start":459},[41,1790,1791,1792,1795,1796,1799,1800,1803],{},"Upload the ",[89,1793,1794],{},".txt"," files to an ",[122,1797,1798],{},"S3 bucket",".\nIf you generated the files inside a container, ",[89,1801,1802],{},"docker cp"," helps extract them.",[41,1805,1806],{},"Import each table using:",[82,1808,1810],{"className":1081,"code":1809,"language":1083,"meta":87,"style":87},"SELECT aws_s3.table_import_from_s3(\n  'users',\n  '',\n  '(format csv, header false, null ''\\N'', escape ''\\'')',\n  'db-name',\n  '\u002Fdumps\u002Fusers.txt',\n  'your-aws-region',\n  'AWS_ACCESS_KEY',\n  'AWS_SECRET_KEY',\n  ''\n);\n",[89,1811,1812,1828,1835,1842,1849,1856,1863,1870,1877,1884,1889],{"__ignoreMap":87},[92,1813,1814,1817,1820,1822,1825],{"class":94,"line":95},[92,1815,1816],{"class":218},"SELECT",[92,1818,1819],{"class":106}," aws_s3",[92,1821,255],{"class":231},[92,1823,1824],{"class":106},"table_import_from_s3",[92,1826,1827],{"class":231},"(\n",[92,1829,1830,1833],{"class":94,"line":151},[92,1831,1832],{"class":102},"  'users'",[92,1834,1157],{"class":231},[92,1836,1837,1840],{"class":94,"line":404},[92,1838,1839],{"class":102},"  ''",[92,1841,1157],{"class":231},[92,1843,1844,1847],{"class":94,"line":412},[92,1845,1846],{"class":102},"  '(format csv, header false, null ''\\N'', escape ''\\'')'",[92,1848,1157],{"class":231},[92,1850,1851,1854],{"class":94,"line":423},[92,1852,1853],{"class":102},"  'db-name'",[92,1855,1157],{"class":231},[92,1857,1858,1861],{"class":94,"line":431},[92,1859,1860],{"class":102},"  '\u002Fdumps\u002Fusers.txt'",[92,1862,1157],{"class":231},[92,1864,1865,1868],{"class":94,"line":440},[92,1866,1867],{"class":102},"  'your-aws-region'",[92,1869,1157],{"class":231},[92,1871,1872,1875],{"class":94,"line":451},[92,1873,1874],{"class":102},"  'AWS_ACCESS_KEY'",[92,1876,1157],{"class":231},[92,1878,1879,1882],{"class":94,"line":459},[92,1880,1881],{"class":102},"  'AWS_SECRET_KEY'",[92,1883,1157],{"class":231},[92,1885,1886],{"class":94,"line":470},[92,1887,1888],{"class":102},"  ''\n",[92,1890,1891],{"class":94,"line":478},[92,1892,977],{"class":231},[16,1894,1895],{},"Repeat this command for each table.",[38,1897,1898],{"start":478},[41,1899,1900],{},"Restart the application:",[82,1902,1904],{"className":84,"code":1903,"language":86,"meta":87,"style":87},"php artisan up\n",[89,1905,1906],{"__ignoreMap":87},[92,1907,1908,1910,1912],{"class":94,"line":95},[92,1909,183],{"class":98},[92,1911,186],{"class":102},[92,1913,1914],{"class":102}," up\n",[11,1916,1918],{"id":1917},"one-more-important-detail","One more important detail",[16,1920,1921],{},"When creating the PostgreSQL schema, there are two approaches:",[1923,1924,1926],"h3",{"id":1925},"option-1-no-foreign-keys-initially","Option 1 — No foreign keys initially",[16,1928,1929,1930,1933],{},"Create tables ",[122,1931,1932],{},"without foreign keys"," so the import order doesn't matter.\nThen apply a second migration to add constraints afterward.",[1923,1935,1937],{"id":1936},"option-2-foreign-keys-from-the-start","Option 2 — Foreign keys from the start",[16,1939,1940,1941,255],{},"Create tables with foreign keys immediately.\nIn this case, the ",[122,1942,1943],{},"table import order becomes important",[11,1945,1947],{"id":1946},"final-thoughts","Final thoughts",[16,1949,1950,1951,1954],{},"For large production databases, a ",[122,1952,1953],{},"schema-first approach with CSV bulk import"," turned out to be much more reliable and faster than using automated migration tools.",[16,1956,1957],{},"It also gave us full control over:",[48,1959,1960,1963,1966,1969,1972],{},[41,1961,1962],{},"data types",[41,1964,1965],{},"indexing strategy",[41,1967,1968],{},"partitioning",[41,1970,1971],{},"extensions",[41,1973,1974],{},"schema cleanup",[147,1976,1977],{},"html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}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 .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}",{"title":87,"searchDepth":151,"depth":151,"links":1979},[1980,1981,1982,1983,1984,1985,1986,1987,1988,1989,1993],{"id":736,"depth":151,"text":737},{"id":786,"depth":151,"text":787},{"id":819,"depth":151,"text":820},{"id":880,"depth":151,"text":881},{"id":1053,"depth":151,"text":1054},{"id":1113,"depth":151,"text":1114},{"id":1202,"depth":151,"text":1203},{"id":1306,"depth":151,"text":1307},{"id":1584,"depth":151,"text":1585},{"id":1917,"depth":151,"text":1918,"children":1990},[1991,1992],{"id":1925,"depth":404,"text":1926},{"id":1936,"depth":404,"text":1937},{"id":1946,"depth":151,"text":1947},"Experience I gained while migrating a large MySQL database to PostgreSQL on AWS RDS.",{"date":1996},"2025-09-11","\u002F2025-09-11-mysql-to-pg",{"title":731,"description":1994},"2025-09-11-mysql-to-pg","PPNXRiStcE_nNU2k6Nr3TSjJlVjvV4ueYYkoAu_VxKs",{"id":2002,"title":2003,"body":2004,"description":2721,"extension":158,"meta":2722,"navigation":161,"path":2724,"seo":2725,"stem":2726,"__hash__":2727},"content\u002F2025-04-25-env-files.md",".env files",{"type":8,"value":2005,"toc":2710},[2006,2010,2013,2016,2019,2021,2025,2031,2038,2053,2056,2062,2065,2068,2070,2074,2077,2080,2086,2092,2095,2105,2108,2115,2118,2120,2124,2127,2137,2148,2151,2154,2156,2160,2169,2179,2182,2206,2209,2215,2218,2220,2227,2233,2236,2245,2248,2250,2254,2263,2675,2681,2684,2686,2689,2692,2695,2704,2707],[11,2007,2009],{"id":2008},"managing-secrets-without-the-pain","Managing Secrets Without the Pain",[16,2011,2012],{},"Secure storage of secrets — and the proper use of this “sensitive” data in environment variables — has been something on my mind for quite a while.",[16,2014,2015],{},"Over time I tried several approaches. Some worked… until they didn’t.",[16,2017,2018],{},"This post is about how I used to manage secrets, the problems that came with it, and how I recently simplified the whole process.",[135,2020],{},[11,2022,2024],{"id":2023},"the-old-approach-local-encryption","The Old Approach: Local Encryption",[16,2026,2027,2028,255],{},"For years my go-to solution was ",[122,2029,2030],{},"local encryption",[16,2032,2033,2034,2037],{},"I relied on good old ",[122,2035,2036],{},"openssl",", which is available on almost any system. The idea was straightforward:",[38,2039,2040,2043,2050],{},[41,2041,2042],{},"Keep a keyfile somewhere safe (in my case, usually a hidden Google Doc).",[41,2044,2045,2046,2049],{},"Encrypt ",[89,2047,2048],{},".env"," files using that key.",[41,2051,2052],{},"Store the encrypted files in the repository.",[16,2054,2055],{},"The resulting files would look something like this:",[82,2057,2060],{"className":2058,"code":2059,"language":1749},[1747],".env.dev.enc\n.env.stage.enc\n.env.prod.enc\n",[89,2061,2059],{"__ignoreMap":87},[16,2063,2064],{},"When needed, I would decrypt them locally, update variables, and encrypt them again.",[16,2066,2067],{},"Simple enough — at least in theory.",[135,2069],{},[11,2071,2073],{"id":2072},"the-problem-with-this-workflow","The Problem With This Workflow",[16,2075,2076],{},"In practice, things became painful pretty quickly.",[16,2078,2079],{},"Every small change required going through the full cycle:",[82,2081,2084],{"className":2082,"code":2083,"language":1749},[1747],"decrypt → edit → encrypt\n",[89,2085,2083],{"__ignoreMap":87},[16,2087,2088,2089,255],{},"And this had to be repeated ",[122,2090,2091],{},"for every environment",[16,2093,2094],{},"After doing this enough times, it became tempting to take shortcuts.",[16,2096,2097,2098,2104],{},"More often than I’d like to admit, I ended up using a ",[122,2099,2100,2101,2103],{},"single shared ",[89,2102,2048],{}," file"," across multiple applications just to make life easier (though who was I really fooling?).",[16,2106,2107],{},"The consequences were predictable.",[16,2109,2110,2111,2114],{},"Sometimes backend environment variables would end up sitting right next to frontend ones — including things like ",[122,2112,2113],{},"database connection credentials"," 🤪.",[16,2116,2117],{},"Not exactly ideal.",[135,2119],{},[11,2121,2123],{"id":2122},"another-issue-separate-secret-storage","Another Issue: Separate Secret Storage",[16,2125,2126],{},"There was also a structural problem.",[16,2128,2129,2130,2132,2133,2136],{},"Encrypted ",[89,2131,2048],{}," files usually lived in a ",[122,2134,2135],{},"separate repository",", which meant:",[48,2138,2139,2142,2145],{},[41,2140,2141],{},"secrets were updated independently",[41,2143,2144],{},"application changes and secret changes were not synchronized",[41,2146,2147],{},"mistakes became easier to make",[16,2149,2150],{},"Any extra moving part in a deployment process increases the chance that something goes wrong.",[16,2152,2153],{},"And this definitely added a few more.",[135,2155],{},[11,2157,2159],{"id":2158},"discovering-doppler","Discovering Doppler",[16,2161,2162,2163,2168],{},"Recently I came across ",[64,2164,2167],{"href":2165,"rel":2166},"https:\u002F\u002Fwww.doppler.com\u002F",[68],"Doppler",".\nIt turned out to be a really nice way to manage secrets.",[16,2170,2171,2172,2174,2175,2178],{},"Instead of juggling encrypted ",[89,2173,2048],{}," files, secrets are stored and managed through a ",[122,2176,2177],{},"clean web interface",", and injected directly when running commands.",[16,2180,2181],{},"For example:",[82,2183,2185],{"className":84,"code":2184,"language":86,"meta":87,"style":87},"doppler run -- npm run build\n",[89,2186,2187],{"__ignoreMap":87},[92,2188,2189,2192,2195,2198,2201,2203],{"class":94,"line":95},[92,2190,2191],{"class":98},"doppler",[92,2193,2194],{"class":102}," run",[92,2196,2197],{"class":106}," --",[92,2199,2200],{"class":102}," npm",[92,2202,2194],{"class":102},[92,2204,2205],{"class":102}," build\n",[16,2207,2208],{},"The application simply runs with the correct configuration profile.",[16,2210,2211,2212,2214],{},"No ",[89,2213,2048],{}," files required.",[16,2216,2217],{},"Even better, Doppler provides integrations with most modern deployment tools.",[135,2219],{},[11,2221,2223,2224,2226],{"id":2222},"why-i-still-keep-env-files","Why I Still Keep ",[89,2225,2048],{}," Files",[16,2228,2229,2230,255],{},"That said, I don’t love being ",[122,2231,2232],{},"completely dependent on a single tool",[16,2234,2235],{},"If the tool disappears tomorrow, I still want my projects to work.",[16,2237,2238,2239,2241,2242,255],{},"So instead of abandoning ",[89,2240,2048],{}," files entirely, I decided to ",[122,2243,2244],{},"generate them automatically using Doppler",[16,2246,2247],{},"This keeps things portable while still benefiting from centralized secret management.",[135,2249],{},[11,2251,2253],{"id":2252},"automating-setup-with-taskfile","Automating Setup With Taskfile",[16,2255,2256,2257,2262],{},"To make things smoother, I created a small ",[64,2258,2261],{"href":2259,"rel":2260},"https:\u002F\u002Ftaskfile.dev\u002F",[68],"Taskfile"," for initializing projects with the correct environment variables:",[82,2264,2266],{"className":377,"code":2265,"language":379,"meta":87,"style":87},"# yaml-language-server: $schema=https:\u002F\u002Ftaskfile.dev\u002Fschema.json\nversion: \"3\"\n\ntasks:\n  install:\n    cmds:\n      - curl -Ls --tlsv1.3 --proto \"=https\" https:\u002F\u002Fcli.doppler.com\u002Finstall.sh | sh\n\n  login:\n    cmds:\n      - doppler login\n  \n  get-env:\n    vars:\n      DOPPLER_PROJECT: '{{default \"help\" .DOPPLER_PROJECT}}'\n      DOPPLER_CONFIG: '{{default \"dev\" .DOPPLER_CONFIG}}'\n      FILEPATH: '{{default \"\" .FILEPATH}}'\n    cmds:\n      - doppler secrets download --project {{.DOPPLER_PROJECT}} --config {{.DOPPLER_CONFIG}} --no-file --format env > {{.FILEPATH}}\n  \n  setup-infra-secrets:\n    cmds:\n      - task: get-env\n        vars: { DOPPLER_PROJECT: \"infra\", DOPPLER_CONFIG: \"{{.DOPPLER_CONFIG}}\", FILEPATH: \".env\" }\n\n  setup-frontend-secrets:\n    cmds:\n      - task: get-env\n        vars: { DOPPLER_PROJECT: \"frontend\", DOPPLER_CONFIG: \"{{.DOPPLER_CONFIG}}\", FILEPATH: \"apps\u002Ffrontend\u002F.env\" }\n  \n  setup-backend-secrets:\n    cmds:\n      - task: get-env\n        vars: { DOPPLER_PROJECT: \"backend\", DOPPLER_CONFIG: \"{{.DOPPLER_CONFIG}}\", FILEPATH: \"apps\u002Fbackend\u002F.env\" }\n\n  setup-secrets:\n    deps: [setup-frontend-secrets, setup-infra-secrets, setup-backend-secrets]\n\n  initial-setup:\n    cmds:\n      - task: install\n      - task: login\n      - task: setup-secrets\n",[89,2267,2268,2273,2283,2287,2294,2301,2308,2316,2320,2327,2333,2340,2345,2352,2359,2369,2379,2389,2395,2402,2406,2413,2419,2430,2469,2473,2480,2486,2496,2528,2532,2539,2545,2555,2587,2591,2598,2622,2626,2633,2639,2651,2663],{"__ignoreMap":87},[92,2269,2270],{"class":94,"line":95},[92,2271,2272],{"class":591},"# yaml-language-server: $schema=https:\u002F\u002Ftaskfile.dev\u002Fschema.json\n",[92,2274,2275,2278,2280],{"class":94,"line":151},[92,2276,2277],{"class":386},"version",[92,2279,398],{"class":231},[92,2281,2282],{"class":102},"\"3\"\n",[92,2284,2285],{"class":94,"line":404},[92,2286,605],{"emptyLinePlaceholder":161},[92,2288,2289,2292],{"class":94,"line":412},[92,2290,2291],{"class":386},"tasks",[92,2293,390],{"class":231},[92,2295,2296,2299],{"class":94,"line":423},[92,2297,2298],{"class":386},"  install",[92,2300,390],{"class":231},[92,2302,2303,2306],{"class":94,"line":431},[92,2304,2305],{"class":386},"    cmds",[92,2307,390],{"class":231},[92,2309,2310,2313],{"class":94,"line":440},[92,2311,2312],{"class":231},"      - ",[92,2314,2315],{"class":102},"curl -Ls --tlsv1.3 --proto \"=https\" https:\u002F\u002Fcli.doppler.com\u002Finstall.sh | sh\n",[92,2317,2318],{"class":94,"line":451},[92,2319,605],{"emptyLinePlaceholder":161},[92,2321,2322,2325],{"class":94,"line":459},[92,2323,2324],{"class":386},"  login",[92,2326,390],{"class":231},[92,2328,2329,2331],{"class":94,"line":470},[92,2330,2305],{"class":386},[92,2332,390],{"class":231},[92,2334,2335,2337],{"class":94,"line":478},[92,2336,2312],{"class":231},[92,2338,2339],{"class":102},"doppler login\n",[92,2341,2342],{"class":94,"line":686},[92,2343,2344],{"class":231},"  \n",[92,2346,2347,2350],{"class":94,"line":1415},[92,2348,2349],{"class":386},"  get-env",[92,2351,390],{"class":231},[92,2353,2354,2357],{"class":94,"line":1421},[92,2355,2356],{"class":386},"    vars",[92,2358,390],{"class":231},[92,2360,2361,2364,2366],{"class":94,"line":1427},[92,2362,2363],{"class":386},"      DOPPLER_PROJECT",[92,2365,398],{"class":231},[92,2367,2368],{"class":102},"'{{default \"help\" .DOPPLER_PROJECT}}'\n",[92,2370,2371,2374,2376],{"class":94,"line":1433},[92,2372,2373],{"class":386},"      DOPPLER_CONFIG",[92,2375,398],{"class":231},[92,2377,2378],{"class":102},"'{{default \"dev\" .DOPPLER_CONFIG}}'\n",[92,2380,2381,2384,2386],{"class":94,"line":1439},[92,2382,2383],{"class":386},"      FILEPATH",[92,2385,398],{"class":231},[92,2387,2388],{"class":102},"'{{default \"\" .FILEPATH}}'\n",[92,2390,2391,2393],{"class":94,"line":1445},[92,2392,2305],{"class":386},[92,2394,390],{"class":231},[92,2396,2397,2399],{"class":94,"line":1451},[92,2398,2312],{"class":231},[92,2400,2401],{"class":102},"doppler secrets download --project {{.DOPPLER_PROJECT}} --config {{.DOPPLER_CONFIG}} --no-file --format env > {{.FILEPATH}}\n",[92,2403,2404],{"class":94,"line":1457},[92,2405,2344],{"class":231},[92,2407,2408,2411],{"class":94,"line":1463},[92,2409,2410],{"class":386},"  setup-infra-secrets",[92,2412,390],{"class":231},[92,2414,2415,2417],{"class":94,"line":1469},[92,2416,2305],{"class":386},[92,2418,390],{"class":231},[92,2420,2421,2423,2425,2427],{"class":94,"line":1475},[92,2422,2312],{"class":231},[92,2424,13],{"class":386},[92,2426,398],{"class":231},[92,2428,2429],{"class":102},"get-env\n",[92,2431,2432,2435,2438,2441,2443,2446,2448,2451,2453,2456,2458,2461,2463,2466],{"class":94,"line":1481},[92,2433,2434],{"class":386},"        vars",[92,2436,2437],{"class":231},": { ",[92,2439,2440],{"class":386},"DOPPLER_PROJECT",[92,2442,398],{"class":231},[92,2444,2445],{"class":102},"\"infra\"",[92,2447,918],{"class":231},[92,2449,2450],{"class":386},"DOPPLER_CONFIG",[92,2452,398],{"class":231},[92,2454,2455],{"class":102},"\"{{.DOPPLER_CONFIG}}\"",[92,2457,918],{"class":231},[92,2459,2460],{"class":386},"FILEPATH",[92,2462,398],{"class":231},[92,2464,2465],{"class":102},"\".env\"",[92,2467,2468],{"class":231}," }\n",[92,2470,2471],{"class":94,"line":1487},[92,2472,605],{"emptyLinePlaceholder":161},[92,2474,2475,2478],{"class":94,"line":1492},[92,2476,2477],{"class":386},"  setup-frontend-secrets",[92,2479,390],{"class":231},[92,2481,2482,2484],{"class":94,"line":1500},[92,2483,2305],{"class":386},[92,2485,390],{"class":231},[92,2487,2488,2490,2492,2494],{"class":94,"line":1506},[92,2489,2312],{"class":231},[92,2491,13],{"class":386},[92,2493,398],{"class":231},[92,2495,2429],{"class":102},[92,2497,2498,2500,2502,2504,2506,2509,2511,2513,2515,2517,2519,2521,2523,2526],{"class":94,"line":1512},[92,2499,2434],{"class":386},[92,2501,2437],{"class":231},[92,2503,2440],{"class":386},[92,2505,398],{"class":231},[92,2507,2508],{"class":102},"\"frontend\"",[92,2510,918],{"class":231},[92,2512,2450],{"class":386},[92,2514,398],{"class":231},[92,2516,2455],{"class":102},[92,2518,918],{"class":231},[92,2520,2460],{"class":386},[92,2522,398],{"class":231},[92,2524,2525],{"class":102},"\"apps\u002Ffrontend\u002F.env\"",[92,2527,2468],{"class":231},[92,2529,2530],{"class":94,"line":1518},[92,2531,2344],{"class":231},[92,2533,2534,2537],{"class":94,"line":1524},[92,2535,2536],{"class":386},"  setup-backend-secrets",[92,2538,390],{"class":231},[92,2540,2541,2543],{"class":94,"line":1530},[92,2542,2305],{"class":386},[92,2544,390],{"class":231},[92,2546,2547,2549,2551,2553],{"class":94,"line":1536},[92,2548,2312],{"class":231},[92,2550,13],{"class":386},[92,2552,398],{"class":231},[92,2554,2429],{"class":102},[92,2556,2557,2559,2561,2563,2565,2568,2570,2572,2574,2576,2578,2580,2582,2585],{"class":94,"line":1542},[92,2558,2434],{"class":386},[92,2560,2437],{"class":231},[92,2562,2440],{"class":386},[92,2564,398],{"class":231},[92,2566,2567],{"class":102},"\"backend\"",[92,2569,918],{"class":231},[92,2571,2450],{"class":386},[92,2573,398],{"class":231},[92,2575,2455],{"class":102},[92,2577,918],{"class":231},[92,2579,2460],{"class":386},[92,2581,398],{"class":231},[92,2583,2584],{"class":102},"\"apps\u002Fbackend\u002F.env\"",[92,2586,2468],{"class":231},[92,2588,2589],{"class":94,"line":1548},[92,2590,605],{"emptyLinePlaceholder":161},[92,2592,2593,2596],{"class":94,"line":1554},[92,2594,2595],{"class":386},"  setup-secrets",[92,2597,390],{"class":231},[92,2599,2600,2603,2606,2609,2611,2614,2616,2619],{"class":94,"line":1560},[92,2601,2602],{"class":386},"    deps",[92,2604,2605],{"class":231},": [",[92,2607,2608],{"class":102},"setup-frontend-secrets",[92,2610,918],{"class":231},[92,2612,2613],{"class":102},"setup-infra-secrets",[92,2615,918],{"class":231},[92,2617,2618],{"class":102},"setup-backend-secrets",[92,2620,2621],{"class":231},"]\n",[92,2623,2624],{"class":94,"line":1566},[92,2625,605],{"emptyLinePlaceholder":161},[92,2627,2628,2631],{"class":94,"line":1572},[92,2629,2630],{"class":386},"  initial-setup",[92,2632,390],{"class":231},[92,2634,2635,2637],{"class":94,"line":1578},[92,2636,2305],{"class":386},[92,2638,390],{"class":231},[92,2640,2642,2644,2646,2648],{"class":94,"line":2641},41,[92,2643,2312],{"class":231},[92,2645,13],{"class":386},[92,2647,398],{"class":231},[92,2649,2650],{"class":102},"install\n",[92,2652,2654,2656,2658,2660],{"class":94,"line":2653},42,[92,2655,2312],{"class":231},[92,2657,13],{"class":386},[92,2659,398],{"class":231},[92,2661,2662],{"class":102},"login\n",[92,2664,2666,2668,2670,2672],{"class":94,"line":2665},43,[92,2667,2312],{"class":231},[92,2669,13],{"class":386},[92,2671,398],{"class":231},[92,2673,2674],{"class":102},"setup-secrets\n",[16,2676,2677,2678,2680],{},"With it, generating the ",[89,2679,2048],{}," file becomes part of the normal project setup.",[16,2682,2683],{},"No manual secret handling required.",[135,2685],{},[11,2687,2688],{"id":1946},"Final Thoughts",[16,2690,2691],{},"Managing secrets is one of those things that seems simple until it isn't.",[16,2693,2694],{},"My old setup worked for a while, but it created too much friction and too many opportunities for mistakes.",[16,2696,2697,2698,2700,2701,255],{},"Using Doppler — while still keeping ",[89,2699,2048],{}," files as a fallback — turned out to be a good balance between ",[122,2702,2703],{},"convenience and control",[16,2705,2706],{},"And honestly, anything that removes a few steps from secret management is already a win.",[147,2708,2709],{},"html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}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 .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .s9eBZ, html code.shiki .s9eBZ{--shiki-default:#22863A;--shiki-dark:#85E89D}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}",{"title":87,"searchDepth":151,"depth":151,"links":2711},[2712,2713,2714,2715,2716,2717,2719,2720],{"id":2008,"depth":151,"text":2009},{"id":2023,"depth":151,"text":2024},{"id":2072,"depth":151,"text":2073},{"id":2122,"depth":151,"text":2123},{"id":2158,"depth":151,"text":2159},{"id":2222,"depth":151,"text":2718},"Why I Still Keep .env Files",{"id":2252,"depth":151,"text":2253},{"id":1946,"depth":151,"text":2688},"My .env files setup",{"date":2723},"2025-04-25","\u002F2025-04-25-env-files",{"title":2003,"description":2721},"2025-04-25-env-files","Xhxi2HzDPCANRJfYPc4ovG1OilApd9sGrVXDUd8SKLo",{"id":2729,"title":2730,"body":2731,"description":3020,"extension":158,"meta":3021,"navigation":161,"path":3023,"seo":3024,"stem":3025,"__hash__":3026},"content\u002F2025-03-25-zed.md","Zed Editor",{"type":8,"value":2732,"toc":3006},[2733,2747,2753,2757,2761,2764,2767,2778,2781,2785,2788,2791,2825,2839,2850,2853,2860,2864,2875,2878,2882,2889,2896,2900,2903,2942,2946,2950,2956,2962,2967,2971,2981,2984,2994,2996,3003],[16,2734,2735,2736,1003,2739,2743,2744,255],{},"A few days ago I tried ",[122,2737,2738],{},"Zed",[64,2740,2741],{"href":2741,"rel":2742},"https:\u002F\u002Fzed.dev\u002F",[68],") — a code editor written in ",[122,2745,2746],{},"Rust",[16,2748,2749,2750,255],{},"Here are some things that immediately stood out ",[122,2751,2752],{},"right out of the box",[11,2754,2756],{"id":2755},"built-in-features","Built-in features",[1923,2758,2760],{"id":2759},"collaborative-mode","Collaborative mode",[16,2762,2763],{},"Zed has a built-in collaborative mode. You can share your local codebase with a teammate and work together in the same environment.",[16,2765,2766],{},"It works surprisingly smoothly:",[48,2768,2769,2772,2775],{},[41,2770,2771],{},"you can talk via voice",[41,2773,2774],{},"follow what your teammate is doing",[41,2776,2777],{},"edit related parts of the code together in real time",[16,2779,2780],{},"It feels a bit like Google Docs, but for your local code.",[1923,2782,2784],{"id":2783},"ai-assistant","AI assistant",[16,2786,2787],{},"Zed includes an AI assistant that can be configured to use different modern LLMs.",[16,2789,2790],{},"I recommend watching the video on their homepage — it gives a good overview of how it works.",[16,2792,2793,2794,2797,2798,2802,2803,2806,2807,2810,2811,918,2814,2817,2818,2821,2822,2824],{},"I experimented with running ",[122,2795,2796],{},"Ollama"," locally (",[64,2799,2800],{"href":2800,"rel":2801},"https:\u002F\u002Follama.com\u002F",[68],") to generate ",[122,2804,2805],{},"JSDoc"," for some of my functions. The only model that produced somewhat reasonable results was ",[122,2808,2809],{},"deepseek-coder",". I also tried ",[122,2812,2813],{},"gemma",[122,2815,2816],{},"llama",", and ",[122,2819,2820],{},"qwen"," (both with and without the ",[89,2823,89],{}," variants), but they didn’t generate anything particularly useful.",[16,2826,2827,2828,2831,2832,2835,2836,255],{},"Running LLMs locally turned out to be ",[122,2829,2830],{},"very resource-intensive",". It requires a lot of RAM, the CPU easily hits ",[122,2833,2834],{},"100%",", and responses can take ",[122,2837,2838],{},"several minutes",[16,2840,2841,2842,2845,2846,2849],{},"The main reason I tried running models locally is that many ",[122,2843,2844],{},"cloud AI providers don’t work in Russia",". I still haven’t gotten around to setting up a ",[122,2847,2848],{},"VPS with a VPN"," to use them instead.",[16,2851,2852],{},"Still, it was an interesting experiment.",[16,2854,2855,2856,2859],{},"Right now, Zed allows you to use its ",[122,2857,2858],{},"built-in assistant (based on Claude 3.5)"," for free because the feature is still new and currently being tested.",[1923,2861,2863],{"id":2862},"familiar-shortcuts","Familiar shortcuts",[16,2865,2866,2867,2870,2871,2874],{},"Zed ships with ",[122,2868,2869],{},"VSCode keybindings by default",", and you can also enable ",[122,2872,2873],{},"Vim mode"," if you prefer that workflow.",[16,2876,2877],{},"I’m not personally a Vim user, but I know many developers appreciate having that option.",[1923,2879,2881],{"id":2880},"multi-buffers-everywhere","Multi-buffers everywhere",[16,2883,2884,2885,2888],{},"One particularly interesting architectural idea: ",[122,2886,2887],{},"everything in Zed is implemented as text buffers",", including the built-in terminal.",[16,2890,2891,2892],{},"More details here:\n",[64,2893,2894],{"href":2894,"rel":2895},"https:\u002F\u002Fzed.dev\u002Ffeatures#multi-buffers",[68],[11,2897,2899],{"id":2898},"extensibility","Extensibility",[16,2901,2902],{},"Zed is surprisingly extensible.",[48,2904,2905,2912,2918,2932,2939],{},[41,2906,2907,2908],{},"Most programming languages are already supported:\n",[64,2909,2910],{"href":2910,"rel":2911},"https:\u002F\u002Fzed.dev\u002Fextensions",[68],[41,2913,2914,2915,255],{},"That’s not surprising considering the Zed team also created ",[122,2916,2917],{},"tree-sitter",[41,2919,2920,2921,545,2924,2927,2928,2931],{},"Extensions for ",[122,2922,2923],{},"Vue",[122,2925,2926],{},"PHP"," work great. Navigation works well — you can jump to definitions, find references, and use ",[122,2929,2930],{},"multi-buffers"," to edit related symbols across multiple files.",[41,2933,2934,2935,2938],{},"A nice bonus: for PHP you can reuse your ",[122,2936,2937],{},"Intelephense license"," if you already use it in VSCode. That makes working with PHP much more comfortable.",[41,2940,2941],{},"Themes, icons, and other customization options are also widely available.",[11,2943,2945],{"id":2944},"my-impressions","My impressions",[1923,2947,2949],{"id":2948},"pros","Pros",[16,2951,2952,2955],{},[122,2953,2954],{},"Performance is incredible.","\nI can open a ridiculous number of files and nothing lags or freezes. Everything feels instant. It honestly feels like my brain starts working faster.",[16,2957,2958,2961],{},[122,2959,2960],{},"Pair programming built in.","\nThis alone could be a big deal for teams.",[16,2963,2964],{},[122,2965,2966],{},"Highly configurable.",[1923,2968,2970],{"id":2969},"cons","Cons",[16,2972,2973,2976,2977],{},[122,2974,2975],{},"SVG support is still lacking.","\nYou can’t preview SVG files directly inside the editor yet. There’s an open issue for this that has been around for about a year:\n",[64,2978,2979],{"href":2979,"rel":2980},"https:\u002F\u002Fgithub.com\u002Fzed-industries\u002Fzed\u002Fissues\u002F10454",[68],[16,2982,2983],{},"Maybe a good opportunity to contribute.",[16,2985,2986,2989,2990,2993],{},[122,2987,2988],{},"Git integration isn’t as deep as in VSCode (yet).","\nResolving merge conflicts is still easier in VSCode. But the basics like ",[122,2991,2992],{},"git blame"," are already there.",[11,2995,1947],{"id":1946},[16,2997,2998,2999,3002],{},"Zed already feels ",[122,3000,3001],{},"extremely fast and promising",". The core ideas behind the editor — performance, collaboration, and multi-buffers — are genuinely interesting.",[16,3004,3005],{},"It’s not a full replacement for VSCode for me yet, but it’s definitely something I’m keeping an eye on.",{"title":87,"searchDepth":151,"depth":151,"links":3007},[3008,3014,3015,3019],{"id":2755,"depth":151,"text":2756,"children":3009},[3010,3011,3012,3013],{"id":2759,"depth":404,"text":2760},{"id":2783,"depth":404,"text":2784},{"id":2862,"depth":404,"text":2863},{"id":2880,"depth":404,"text":2881},{"id":2898,"depth":151,"text":2899},{"id":2944,"depth":151,"text":2945,"children":3016},[3017,3018],{"id":2948,"depth":404,"text":2949},{"id":2969,"depth":404,"text":2970},{"id":1946,"depth":151,"text":1947},"Impressions of Zed Editor",{"date":3022},"2025-03-20","\u002F2025-03-25-zed",{"title":2730,"description":3020},"2025-03-25-zed","8aQ6mmZ8CpqX_sAdrxASd2dli2ElNuatzmnsq45ILfs",1788954571985]