[{"data":1,"prerenderedAt":5626},["ShallowReactive",2],{"blog-posts":3},[4,233,321,472,1033,2305,3032,3331,3534,3614,3723,4075,4973,5446],{"id":5,"title":6,"body":7,"description":224,"extension":225,"meta":226,"navigation":228,"path":229,"seo":230,"stem":231,"__hash__":232},"content\u002Fen\u002F2026-09-07-hotel-admin-part-2.md","A developer somehow ends up running hotel IT. Part 2: Replacing the core switches and trying not to break anything",{"type":8,"value":9,"toc":220},"minimark",[10,27,30,48,71,74,77,84,87,90,93,96,102,105,140,143,146,149,161,164,167,178,185,201,204,207,214],[11,12,13,14,18,19,22,23,26],"p",{},"At some point, I decided that before adding a separate VLAN for guest Wi-Fi, I should probably sort out the network core itself. At the time, it consisted of several generations of ",[15,16,17],"code",{},"D-Link"," switches, an old ",[15,20,21],{},"HPE 1910-24",", and a ",[15,24,25],{},"Zyxel GS1900-24HP",". Some of the equipment had a clear purpose. Some of it had apparently just ended up between two other switches at some point in history and stayed there under the principle of \"if it works, don't touch it.\"",[11,28,29],{},"For replacements, I had:",[31,32,33,41],"ul",{},[34,35,36,37,40],"li",{},"an inherited ",[15,38,39],{},"Eltex MES2428P"," that was sitting powered off;",[34,42,43,44,47],{},"two new ",[15,45,46],{},"Eltex MES2424P"," switches.",[11,49,50,51,54,55,57,58,60,61,63,64,67,68,70],{},"The plan was to replace the ",[15,52,53],{},"Zyxel"," with the ",[15,56,39],{}," from the server room, replace the two core ",[15,59,17],{}," switches with the new ",[15,62,46],{}," units, remove part of the old chain entirely, and move the client connections from the old ",[15,65,66],{},"HPE"," to the ",[15,69,25],{}," I already had.",[11,72,73],{},"Sounds simple: save the configs → move the cables → done. Of course not.",[11,75,76],{},"First, I discovered that I needed to work out where all those cables actually went. Some port labels were out of date, some devices had no useful names, and some connections existed only in old diagrams that had long since stopped matching reality. So before replacing anything, I pieced the network back together:",[11,78,79,80,83],{},"SNMP, LLDP, MAC address tables, DHCP, ARP, port status, and a bit of walking between racks. AI tools helped a lot here, because parsing SNMP data while staring at keys like ",[15,81,82],{},"1.3.6.1.2.1.1.5.0"," (OIDs) is quite the treat.",[11,85,86],{},"Gradually, a table took shape:",[11,88,89],{},"old port → actual device → VLAN → new port.",[11,91,92],{},"A cable plugged into a disabled port couldn't automatically be considered unnecessary. But moving it blindly wasn't an option either.",[11,94,95],{},"The old VLANs were a separate source of entertainment. The previous switches still had several VLANs without a single active device in them. I wasn't going to copy them onto the new hardware just because they'd once existed. Only networks that were actually in use made it into the new configuration.",[11,97,98,99,101],{},"I initially configured the ",[15,100,46],{}," switches separately from the production network. Hostnames, LLDP, RSTP, root bridge priorities, access ports, tagged and untagged VLANs, PVID, ingress filtering.",[11,103,104],{},"On paper, it all sounds fairly straightforward. Well, once you know what all those abbreviations mean.",[11,106,107,108,111,112,115,116,119,120,123,124,127,128,131,132,135,136,139],{},"In practice, even connecting to the console turned into a quest of its own: an FTDI adapter, 115200 8N1, ",[15,109,110],{},"screen"," on macOS, and a certain number of question marks instead of a proper command prompt. In the end, I used a utility called ",[15,113,114],{},"dhcpmasque"," on my Mac to act as a ",[15,117,118],{},"DHCP"," server when connected to the switch and assign it the IP address I wanted based on its ",[15,121,122],{},"MAC"," address. I found that address by catching the switch's ",[15,125,126],{},"DHCPREQUEST"," during startup with ",[15,129,130],{},"tcpdump",". Although the ",[15,133,134],{},"Eltex"," documentation said the switches shipped with a static address, that wasn't the case: they were waiting for a ",[15,137,138],{},"DHCPACK",", so I had to improvise.",[11,141,142],{},"A very reassuring start to the migration.",[11,144,145],{},"I didn't configure static management addresses on the new switches. Instead, I prepared DHCP reservations so that once an old device was disconnected, its replacement could take over the same address.",[11,147,148],{},"The actual migration happened in stages.",[150,151,152,155,158],"ol",{},[34,153,154],{},"First, the main uplink.",[34,156,157],{},"Then, check management access, LLDP, and connectivity.",[34,159,160],{},"Only after that, move the remaining connections.",[11,162,163],{},"The old switches weren't reset or taken apart. They stayed ready as a physical rollback option: if anything went wrong, put the labelled cables back. As with the previous post, these switches could only be replaced at night: they were the core switches, and taking them offline would cut off the entire hotel's network.",[11,165,166],{},"Something did, of course, go wrong.",[11,168,169,170,173,174,177],{},"After moving one of the external links, the internet started dropping out. It turned out the provider was sending ",[15,171,172],{},"BPDUs",": ",[15,175,176],{},"STP"," packets that network equipment uses to work out who's in charge. The new Eltex dutifully processed them through RSTP and, at some point, blocked the port because it considered itself the \"root\" switch, meaning the one in charge. And under the Spanning Tree Protocol rules, only one switch can be in charge. \"There can be only one!\"",[11,179,180,181,184],{},"I'd forgotten that I had enabled BPDU blocking on the provider-facing port of the old D-Link. 🙈 I repeated that bit of magic on the Eltex, and the uplink promptly came back. ",[15,182,183],{},"RSTP"," stayed enabled on the internal connections as before to prevent accidental network loops.",[11,186,187,188,191,192,194,195,197,198,200],{},"Then another surprise turned up. The cable leading to one of the access switches, a ",[15,189,190],{},"UniFi US8",", worked fine through the old HPE, but wouldn't establish a link at all when connected directly to the new Eltex or the Zyxel. I could have spent a long time blaming equipment compatibility. But the fact that the connection through the HPE only came up at 100 Mbps was a pretty clear hint that the physical cable run was the problem: the termination, the patch panel, or missing wire pairs. I didn't have a crimping tool, a punch-down tool, or an Ethernet tester with me, so the old ",[15,193,66],{}," had to stay in the network temporarily. Almost everything else had already been moved off it, but the switch kept working as a rather large, power-hungry adapter between one cable and the UniFi. In the end, the core D-Link switches were replaced with ",[15,196,46],{}," units, the client connections moved to the ",[15,199,25],{},", an unnecessary intermediate switch was removed, and the main nodes were left with proper fibre links between them.",[11,202,203],{},"For me, the biggest result wasn't even the new switches. After this migration, the network finally had a diagram I could more or less trust.",[11,205,206],{},"With real ports, VLANs, uplinks, exceptions, and a clear rollback plan. With that done, I could move on to the next tricky task: putting guest Wi-Fi on its own VLAN. Stay tuned.",[11,208,209],{},[210,211],"img",{"alt":212,"src":213},"Anonymised network diagram showing ports, VLANs, and equipment models; labels are in Russian","\u002Fassets\u002Fimages\u002Fblog\u002Fhotel-admin\u002Fnetwork-map.png",[11,215,216],{},[210,217],{"alt":218,"src":219},"Eltex MES2428P switch","\u002Fassets\u002Fimages\u002Fblog\u002Fhotel-admin\u002Feltex-mes2428p.png",{"title":221,"searchDepth":222,"depth":222,"links":223},"",2,[],"Replacing the core switches with Eltex, mapping the network, unexpected BPDUs, and an old HPE temporarily serving as an adapter.","md",{"date":227},"2026-09-07",true,"\u002Fen\u002F2026-09-07-hotel-admin-part-2",{"title":6,"description":224},"en\u002F2026-09-07-hotel-admin-part-2","BQHCk3ZIMxbIsx9-Kp9pXqSTHq9WRKhW9Ni5t1m0J1E",{"id":234,"title":235,"body":236,"description":314,"extension":225,"meta":315,"navigation":228,"path":317,"seo":318,"stem":319,"__hash__":320},"content\u002Fen\u002F2026-09-04-hotel-admin-part-1.md","A developer somehow ends up running hotel IT. Part 1: Replacing a server CPU",{"type":8,"value":237,"toc":312},[238,241,244,247,250,267,275,278,281,284,287,290,297,300,303,306],[11,239,240],{},"There's a hotel whose infrastructure I've somehow ended up maintaining for the past six months, since April this year.",[11,242,243],{},"Sometime in May, after a month of getting my bearings, it became clear that one of the SuperMicro servers could use an upgrade.",[11,245,246],{},"I'm actually a fullstack developer.",[11,248,249],{},"So, naturally, a little while later I found myself figuring out:",[31,251,252,255,258,261,264],{},[34,253,254],{},"what socket it had;",[34,256,257],{},"which Xeons the motherboard supported;",[34,259,260],{},"whether it needed a BIOS update;",[34,262,263],{},"whether the cooling could handle it;",[34,265,266],{},"and which CPU was even worth buying for this ancient machine.",[11,268,269,270,274],{},"I picked a CPU. To my surprise, a processor for this platform from 2017 (or was it 2018?) cost just 4,000 rubles (?!). An ",[271,272,273],"em",{},"Intel(R) Xeon(R) Gold 6138 CPU @ 2.00GHz",". And it would give me almost five times the CPU capacity. From 4 cores to 20. Not a bad upgrade, right? We also picked up some more RAM: we had 64 GB and were aiming for around 190 GB. Order placed.",[11,276,277],{},"Then came the especially wonderful part: someone also had to physically replace the CPU and memory in the server.",[11,279,280],{},"That someone, of course, turned out to be me as well.",[11,282,283],{},"The server runs Proxmox with all the software the hotel needs. So you can't just shut it down in the middle of a workday. Or on a weekend, for that matter. It's a hotel, after all. 😅",[11,285,286],{},"So there I was at 3 a.m.: server taken apart, heatsink removed, old Xeon out, new one in, thermal paste applied, server put back together.",[11,288,289],{},"I pressed the power button. Wiped the sweat off my forehead like that pilot in the famous GIF trying to land a plane.",[11,291,292,293,296],{},"For some reason, those few seconds before anything appeared on the monitor were far more nerve-racking than any ",[15,294,295],{},"docker stack deploy",".",[11,298,299],{},"It booted.",[11,301,302],{},"And just like that, upgrading server Xeons quietly became part of my stack.",[11,304,305],{},"I've attached photos of the old processor being removed. Ba-dum-tss. Unfortunately, the whole machine was covered in construction dust because someone had drilled a hole in the wall right above the server to run some cables or pipes. I vacuumed it as best I could, but didn't take an \"after\" photo: I just wanted to get it back together and check that it worked.",[11,307,308],{},[210,309],{"alt":310,"src":311},"Replacing the server CPU","\u002Fassets\u002Fimages\u002Fblog\u002Fhotel-admin\u002Fserver-cpu-1.jpg",{"title":221,"searchDepth":222,"depth":222,"links":313},[],"A late-night SuperMicro server upgrade: a Xeon Gold 6138, going from 4 cores to 20, and a nervous wait for the first boot.",{"date":316},"2026-09-04","\u002Fen\u002F2026-09-04-hotel-admin-part-1",{"title":235,"description":314},"en\u002F2026-09-04-hotel-admin-part-1","20kxEVgg3pTiOHbfLHFpfU_quSDqmEKUpl-hXZQjTFk",{"id":322,"title":323,"body":324,"description":465,"extension":225,"meta":466,"navigation":228,"path":468,"seo":469,"stem":470,"__hash__":471},"content\u002Fen\u002F2026-04-13-remote-raspberries.md","Remote connection to local network without public gateway",{"type":8,"value":325,"toc":459},[326,331,334,338,341,345,348,352,443,446,455],[327,328,330],"h2",{"id":329},"task","Task",[11,332,333],{},"Be able to connect to a remote local network.",[327,335,337],{"id":336},"purpose","Purpose",[11,339,340],{},"For example, to manage remote devices such as routers, WiFi access points, etc.",[327,342,344],{"id":343},"constraints","Constraints",[11,346,347],{},"There is no working host and no public IP.",[327,349,351],{"id":350},"my-solution","My Solution",[150,353,354,357,381,389,422,435],{},[34,355,356],{},"Buy Raspberry Pi 4.",[34,358,359,360],{},"Prepare the device:",[31,361,362,368,371],{},[34,363,364,365],{},"Install Raspberry Pi OS Lite. ",[271,366,367],{},"Lite is important to avoid unnecessary load since we don’t need a GUI (X server).",[34,369,370],{},"Make sure the device is connected to Raspberry Pi Connect so it’s always reachable as long as it has internet access.",[34,372,373,374,380],{},"Install Tailscale (",[375,376,377],"a",{"href":377,"rel":378},"https:\u002F\u002Ftailscale.com\u002F",[379],"nofollow",") and connect it to your account.",[34,382,383,384],{},"On-site setup:",[31,385,386],{},[34,387,388],{},"Connect the Raspberry Pi via Ethernet (RJ-45). I chose a wired connection to ensure stability and avoid connectivity loss.",[34,390,391,392,417],{},"The most important part:",[393,394,398],"pre",{"className":395,"code":396,"language":397,"meta":221,"style":221},"language-bash shiki shiki-themes github-light github-dark","tailscale up --advertise-routes=192.168.x.x\u002F24\n","bash",[15,399,400],{"__ignoreMap":221},[401,402,405,409,413],"span",{"class":403,"line":404},"line",1,[401,406,408],{"class":407},"sScJk","tailscale",[401,410,412],{"class":411},"sZZnC"," up",[401,414,416],{"class":415},"sj4cs"," --advertise-routes=192.168.x.x\u002F24\n",[31,418,419],{},[34,420,421],{},"This shares the local subnet with Tailscale clients, allowing access to devices like routers (e.g., 192.168.1.1) within that network.",[34,423,424,425],{},"In the Tailscale admin panel:",[31,426,427],{},[34,428,429,430,434],{},"Enable the route via ",[431,432,433],"strong",{},"Edit Route Settings → Subnet routes"," to grant access to the subnet.",[34,436,437,438],{},"Voilà!",[31,439,440],{},[34,441,442],{},"You can now connect to the local network where your Raspberry Pi is deployed.",[444,445],"hr",{},[11,447,448,451,454],{},[431,449,450],{},"P.S.",[452,453],"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.",[456,457,458],"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":221,"searchDepth":222,"depth":222,"links":460},[461,462,463,464],{"id":329,"depth":222,"text":330},{"id":336,"depth":222,"text":337},{"id":343,"depth":222,"text":344},{"id":350,"depth":222,"text":351},"Raspberry Pi and Tailscale to the rescue!",{"date":467},"2026-04-13","\u002Fen\u002F2026-04-13-remote-raspberries",{"title":323,"description":465},"en\u002F2026-04-13-remote-raspberries","VNQSdGqs09cydcAUdpmFm_fB4KdVf8eyCF_HZ0MTvKQ",{"id":473,"title":474,"body":475,"description":1026,"extension":225,"meta":1027,"navigation":228,"path":1029,"seo":1030,"stem":1031,"__hash__":1032},"content\u002Fen\u002F2025-10-12-laravel-cron-jobs.md","Running Laravel Cron Jobs in Docker with FrankenPHP",{"type":8,"value":476,"toc":1019},[477,480,505,512,515,555,561,572,606,616,618,626,635,645,648,654,657,659,663,673,680,786,789,839,854,857,859,863,870,884,994,996,1000,1007,1016],[11,478,479],{},"Yesterday I spent some time digging into how cron jobs work in Laravel. There are two fairly straightforward commands involved:",[393,481,483],{"className":395,"code":482,"language":397,"meta":221,"style":221},"php artisan schedule:run\nphp artisan schedule:work\n",[15,484,485,496],{"__ignoreMap":221},[401,486,487,490,493],{"class":403,"line":404},[401,488,489],{"class":407},"php",[401,491,492],{"class":411}," artisan",[401,494,495],{"class":411}," schedule:run\n",[401,497,498,500,502],{"class":403,"line":222},[401,499,489],{"class":407},[401,501,492],{"class":411},[401,503,504],{"class":411}," schedule:work\n",[11,506,507,508,511],{},"The idea is that the first command should be placed into the ",[431,509,510],{},"actual system crontab"," in whatever environment runs your backend (or any environment that has the required environment variables and access to the database).",[11,513,514],{},"Your crontab entry usually looks something like this:",[393,516,518],{"className":395,"code":517,"language":397,"meta":221,"style":221},"* * * * * cd \u002Fpath-to-your-project && php artisan schedule:run >> \u002Fdev\u002Fnull 2>&1\n",[15,519,520],{"__ignoreMap":221},[401,521,522,526,529,531,533,535,539,541,543,546,549,552],{"class":403,"line":404},[401,523,525],{"class":524},"szBVR","*",[401,527,528],{"class":524}," *",[401,530,528],{"class":524},[401,532,528],{"class":524},[401,534,528],{"class":524},[401,536,538],{"class":537},"sVt8B"," cd \u002Fpath-to-your-project && ",[401,540,489],{"class":407},[401,542,492],{"class":411},[401,544,545],{"class":411}," schedule:run",[401,547,548],{"class":524}," >>",[401,550,551],{"class":411}," \u002Fdev\u002Fnull",[401,553,554],{"class":524}," 2>&1\n",[11,556,557,558,296],{},"This means the command runs ",[431,559,560],{},"every minute",[11,562,563,564,567,568,571],{},"The idea behind this approach is to delegate the scheduling logic to ",[431,565,566],{},"PHP and Laravel",". When this command runs, Laravel checks ",[15,569,570],{},"app\u002FConsole\u002FKernel.php"," for registered scheduled tasks. They typically look something like this:",[393,573,576],{"className":574,"code":575,"language":489,"meta":221,"style":221},"language-php shiki shiki-themes github-light github-dark","$schedule->command('emails:send Taylor --force')->daily();\n",[15,577,578],{"__ignoreMap":221},[401,579,580,583,586,589,592,595,598,600,603],{"class":403,"line":404},[401,581,582],{"class":537},"$schedule",[401,584,585],{"class":524},"->",[401,587,588],{"class":407},"command",[401,590,591],{"class":537},"(",[401,593,594],{"class":411},"'emails:send Taylor --force'",[401,596,597],{"class":537},")",[401,599,585],{"class":524},[401,601,602],{"class":407},"daily",[401,604,605],{"class":537},"();\n",[11,607,608,609,612,613,296],{},"In this case, ",[15,610,611],{},"->daily()"," means the command is expected to run ",[431,614,615],{},"once per day",[444,617],{},[327,619,621,622,625],{"id":620},"what-does-schedulework-do","What does ",[15,623,624],{},"schedule:work"," do?",[11,627,628,629,631,632,296],{},"According to the documentation, the second command (",[15,630,624],{},") is intended ",[431,633,634],{},"only for local development",[11,636,637,638,641,642,296],{},"All it really does is run ",[15,639,640],{},"schedule:run"," every minute, but implemented in PHP using an ",[431,643,644],{},"infinite loop",[11,646,647],{},"You can see the original implementation here:",[11,649,650],{},[375,651,652],{"href":652,"rel":653},"https:\u002F\u002Fgithub.com\u002Flaravel\u002Fframework\u002Fpull\u002F34618\u002Ffiles",[379],[11,655,656],{},"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.",[444,658],{},[327,660,662],{"id":661},"my-setup-docker-frankenphp","My setup: Docker + FrankenPHP",[11,664,665,666,669,670,296],{},"I run my backend application in Docker using ",[431,667,668],{},"docker-compose",".\nThe base image is ",[15,671,672],{},"dunglas\u002Ffrankenphp",[11,674,675,676,679],{},"Here’s how the service is defined in ",[15,677,678],{},"docker-compose.yml",":",[393,681,685],{"className":682,"code":683,"language":684,"meta":221,"style":221},"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",[15,686,687,696,706,714,725,733,742,753,761,772,780],{"__ignoreMap":221},[401,688,689,693],{"class":403,"line":404},[401,690,692],{"class":691},"s9eBZ","backend-cron",[401,694,695],{"class":537},":\n",[401,697,698,701,703],{"class":403,"line":222},[401,699,700],{"class":691},"  restart",[401,702,173],{"class":537},[401,704,705],{"class":411},"unless-stopped\n",[401,707,709,712],{"class":403,"line":708},3,[401,710,711],{"class":691},"  build",[401,713,695],{"class":537},[401,715,717,720,722],{"class":403,"line":716},4,[401,718,719],{"class":691},"    target",[401,721,173],{"class":537},[401,723,724],{"class":411},"dev\n",[401,726,728,731],{"class":403,"line":727},5,[401,729,730],{"class":691},"  volumes",[401,732,695],{"class":537},[401,734,736,739],{"class":403,"line":735},6,[401,737,738],{"class":537},"    - ",[401,740,741],{"class":411},".:\u002Fapp\n",[401,743,745,748,750],{"class":403,"line":744},7,[401,746,747],{"class":691},"  command",[401,749,173],{"class":537},[401,751,752],{"class":411},"\"frankenphp php-cli artisan schedule:work\"\n",[401,754,756,759],{"class":403,"line":755},8,[401,757,758],{"class":691},"  healthcheck",[401,760,695],{"class":537},[401,762,764,767,769],{"class":403,"line":763},9,[401,765,766],{"class":691},"    disable",[401,768,173],{"class":537},[401,770,771],{"class":415},"true\n",[401,773,775,778],{"class":403,"line":774},10,[401,776,777],{"class":691},"  env_file",[401,779,695],{"class":537},[401,781,783],{"class":403,"line":782},11,[401,784,785],{"class":411},"    .env\n",[11,787,788],{},"Every minute I was getting the same error:",[393,790,792],{"className":395,"code":791,"language":397,"meta":221,"style":221},"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",[15,793,794,820],{"__ignoreMap":221},[401,795,796,799,802,805,808,811,814,817],{"class":403,"line":404},[401,797,798],{"class":407},"backend-cron-1",[401,800,801],{"class":524},"  |",[401,803,804],{"class":407}," 2025-12-09T14:54:00.043306279Z",[401,806,807],{"class":411}," sh:",[401,809,810],{"class":411}," 1:",[401,812,813],{"class":411}," :",[401,815,816],{"class":411}," Permission",[401,818,819],{"class":411}," denied\n",[401,821,822,824,826,829,831,833,835,837],{"class":403,"line":222},[401,823,798],{"class":407},[401,825,801],{"class":524},[401,827,828],{"class":407}," 2025-12-09T14:55:00.041940906Z",[401,830,807],{"class":411},[401,832,810],{"class":411},[401,834,813],{"class":411},[401,836,816],{"class":411},[401,838,819],{"class":411},[11,840,841,842,845,846,849,850,853],{},"At first, based on past experience, I assumed the script simply ",[431,843,844],{},"didn't have permission to write to the filesystem",". This is a common Laravel issue when running inside Docker, usually related to the ",[15,847,848],{},"storage"," and ",[15,851,852],{},"bootstrap\u002Fcache"," directories.",[11,855,856],{},"But that wasn’t the case here.",[444,858],{},[327,860,862],{"id":861},"the-actual-problem","The actual problem",[11,864,865,866,869],{},"It turned out that ",[431,867,868],{},"FrankenPHP cannot spawn subprocesses"," in this setup for some reason.",[11,871,872,873,876,877,880,881,883],{},"I tried playing around with ",[15,874,875],{},"setcap"," in the Docker image, but eventually gave up and just wrote a simple ",[431,878,879],{},"bash script"," that replicates what ",[15,882,624],{}," does:",[393,885,889],{"className":886,"code":887,"language":888,"meta":221,"style":221},"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",[15,890,891,897,905,910,918,922,936,941,967,971,976,988],{"__ignoreMap":221},[401,892,893],{"class":403,"line":404},[401,894,896],{"class":895},"sJ8bj","#!\u002Fbin\u002Fsh\n",[401,898,899,902],{"class":403,"line":222},[401,900,901],{"class":415},"set",[401,903,904],{"class":415}," -e\n",[401,906,907],{"class":403,"line":708},[401,908,909],{"emptyLinePlaceholder":228},"\n",[401,911,912,915],{"class":403,"line":716},[401,913,914],{"class":415},"echo",[401,916,917],{"class":411}," \"Starting schedule loop...\"\n",[401,919,920],{"class":403,"line":727},[401,921,909],{"emptyLinePlaceholder":228},[401,923,924,927,930,933],{"class":403,"line":735},[401,925,926],{"class":524},"while",[401,928,929],{"class":415}," true",[401,931,932],{"class":537},"; ",[401,934,935],{"class":524},"do\n",[401,937,938],{"class":403,"line":744},[401,939,940],{"class":895},"    # Wait until the start of the next minute\n",[401,942,943,946,949,952,955,958,961,964],{"class":403,"line":755},[401,944,945],{"class":407},"    sleep",[401,947,948],{"class":537}," $((",[401,950,951],{"class":407},"60",[401,953,954],{"class":411}," -",[401,956,957],{"class":537}," $(",[401,959,960],{"class":407},"date",[401,962,963],{"class":411}," +%S",[401,965,966],{"class":537},")))\n",[401,968,969],{"class":403,"line":763},[401,970,909],{"emptyLinePlaceholder":228},[401,972,973],{"class":403,"line":774},[401,974,975],{"class":895},"    # Run the scheduler\n",[401,977,978,981,984,986],{"class":403,"line":782},[401,979,980],{"class":407},"    frankenphp",[401,982,983],{"class":411}," php-cli",[401,985,492],{"class":411},[401,987,495],{"class":411},[401,989,991],{"class":403,"line":990},12,[401,992,993],{"class":524},"done\n",[444,995],{},[327,997,999],{"id":998},"thoughts-on-frankenphp","Thoughts on FrankenPHP",[11,1001,1002,1003,1006],{},"Up until this point, I hadn't experienced any issues with FrankenPHP. It really worked as a ",[431,1004,1005],{},"drop-in replacement"," for the images I used before.",[11,1008,1009,1010,1015],{},"I haven't tried ",[375,1011,1014],{"href":1012,"rel":1013},"https:\u002F\u002Ffrankenphp.dev\u002Fdocs\u002Fworker\u002F",[379],"worker mode"," yet mostly because I expect it will require quite a bit of tweaking in the application itself to get everything running properly.",[456,1017,1018],{},"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":221,"searchDepth":222,"depth":222,"links":1020},[1021,1023,1024,1025],{"id":620,"depth":222,"text":1022},"What does schedule:work do?",{"id":661,"depth":222,"text":662},{"id":861,"depth":222,"text":862},{"id":998,"depth":222,"text":999},"How to run Laravel cron jobs in Docker using FrankenPHP",{"date":1028},"2025-10-12","\u002Fen\u002F2025-10-12-laravel-cron-jobs",{"title":474,"description":1026},"en\u002F2025-10-12-laravel-cron-jobs","vb6ud0AodW65j5HbEv1Ikd9kbTKNfpzVMjo5cEXSYKU",{"id":1034,"title":1035,"body":1036,"description":2298,"extension":225,"meta":2299,"navigation":228,"path":2301,"seo":2302,"stem":2303,"__hash__":2304},"content\u002Fen\u002F2025-09-11-mysql-to-pg.md","Migrating a Large MySQL Database to PostgreSQL on AWS RDS",{"type":8,"value":1037,"toc":2282},[1038,1042,1045,1088,1092,1095,1102,1121,1125,1131,1134,1157,1165,1179,1182,1186,1193,1196,1199,1344,1355,1359,1366,1372,1383,1412,1415,1419,1425,1431,1433,1504,1508,1519,1558,1565,1576,1608,1612,1615,1622,1633,1640,1643,1886,1890,1899,1902,1931,1938,1959,1973,1978,2045,2048,2056,2059,2091,2111,2197,2200,2205,2219,2223,2226,2231,2238,2242,2248,2252,2259,2262,2279],[327,1039,1041],{"id":1040},"preconditions","Preconditions",[11,1043,1044],{},"Before starting the migration, here was the situation:",[31,1046,1047,1058,1065,1072,1078],{},[34,1048,1049,1050,1053,1054,1057],{},"The new PostgreSQL database had to run on ",[431,1051,1052],{},"AWS RDS"," (so ",[431,1055,1056],{},"no access to the database host filesystem",").",[34,1059,1060,1061,1064],{},"Some tables contained ",[431,1062,1063],{},"millions of rows"," (~1.5 GB per table).",[34,1066,1067,1068,1071],{},"The schema included ",[431,1069,1070],{},"JSON columns with virtual columns derived from JSON"," (used for indexing).",[34,1073,1074,1075,296],{},"The database used ",[431,1076,1077],{},"ENUM types",[34,1079,1080,1081,1084,1085,296],{},"The backend is written in ",[431,1082,1083],{},"Laravel",", with heavy use of ",[431,1086,1087],{},"database migrations",[327,1089,1091],{"id":1090},"where-to-start","Where to start?",[11,1093,1094],{},"The first step was researching standard migration tools.",[11,1096,1097,1098,1101],{},"The most popular option is ",[431,1099,1100],{},"pgloader",". I also found a couple of Node.js-based tools. I ended up discarding both approaches because:",[31,1103,1104,1114],{},[34,1105,1106,1107,1110,1111,1057],{},"Many column types were not mapped correctly (for example, ",[15,1108,1109],{},"json"," not becoming ",[15,1112,1113],{},"jsonb",[34,1115,1116,1117,1120],{},"The migration process itself was ",[431,1118,1119],{},"too slow",", which is unacceptable when dealing with production data.",[327,1122,1124],{"id":1123},"schema-first-approach","Schema-first approach",[11,1126,1127,1128,296],{},"I eventually concluded that the ",[431,1129,1130],{},"PostgreSQL schema should be created manually before migrating the data",[11,1132,1133],{},"This decision was driven by several factors:",[31,1135,1136,1139,1145,1151],{},[34,1137,1138],{},"Type mismatches mentioned earlier",[34,1140,1141,1142],{},"The need to ",[431,1143,1144],{},"modify the schema during migration",[34,1146,1147,1148],{},"Adding ",[431,1149,1150],{},"table partitioning",[34,1152,1153,1154],{},"Removing ",[431,1155,1156],{},"deprecated tables",[11,1158,1159,1160],{},"To achieve this, I generated a Laravel-compatible migration for the entire existing database using ",[375,1161,1164],{"href":1162,"rel":1163},"https:\u002F\u002Fgithub.com\u002Fkitloong\u002Flaravel-migrations-generator",[379],"laravel-migrations-generator",[11,1166,1167,1168,1171,1172,1175,1176,296],{},"I used the ",[15,1169,1170],{},"--squash"," flag to produce ",[431,1173,1174],{},"one large migration file"," and removed all previous migrations. This allowed new environments to start with the ",[431,1177,1178],{},"current schema from scratch",[11,1180,1181],{},"Naturally, the generated migration required some adjustments.",[327,1183,1185],{"id":1184},"handling-enum-types","Handling ENUM types",[11,1187,1188,1189,1192],{},"Laravel handles enums in PostgreSQL as ",[431,1190,1191],{},"check constraints",", not as real database types. I wanted proper PostgreSQL enum types.",[11,1194,1195],{},"To make that work, I extended Laravel’s schema grammar so migrations could reference custom types.",[11,1197,1198],{},"Example:",[393,1200,1202],{"className":574,"code":1201,"language":489,"meta":221,"style":221},"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",[15,1203,1204,1229,1240,1245,1249,1282,1286,1314,1319,1340],{"__ignoreMap":221},[401,1205,1206,1209,1212,1215,1217,1220,1223,1226],{"class":403,"line":404},[401,1207,1208],{"class":415},"Grammar",[401,1210,1211],{"class":524},"::",[401,1213,1214],{"class":407},"macro",[401,1216,591],{"class":537},[401,1218,1219],{"class":411},"'typeCountry'",[401,1221,1222],{"class":537},", ",[401,1224,1225],{"class":524},"function",[401,1227,1228],{"class":537}," () {\n",[401,1230,1231,1234,1237],{"class":403,"line":222},[401,1232,1233],{"class":524},"    return",[401,1235,1236],{"class":411}," 'country'",[401,1238,1239],{"class":537},";\n",[401,1241,1242],{"class":403,"line":708},[401,1243,1244],{"class":537},"});\n",[401,1246,1247],{"class":403,"line":716},[401,1248,909],{"emptyLinePlaceholder":228},[401,1250,1251,1254,1256,1259,1261,1264,1267,1270,1273,1276,1279],{"class":403,"line":727},[401,1252,1253],{"class":415},"DB",[401,1255,1211],{"class":524},[401,1257,1258],{"class":407},"statement",[401,1260,591],{"class":537},[401,1262,1263],{"class":411},"\"",[401,1265,1266],{"class":524},"CREATE",[401,1268,1269],{"class":524}," TYPE",[401,1271,1272],{"class":411}," country ",[401,1274,1275],{"class":524},"AS",[401,1277,1278],{"class":411}," ENUM('russia', 'china', 'germany');\"",[401,1280,1281],{"class":537},");\n",[401,1283,1284],{"class":403,"line":735},[401,1285,909],{"emptyLinePlaceholder":228},[401,1287,1288,1291,1293,1296,1298,1301,1303,1305,1308,1311],{"class":403,"line":744},[401,1289,1290],{"class":415},"Schema",[401,1292,1211],{"class":524},[401,1294,1295],{"class":407},"create",[401,1297,591],{"class":537},[401,1299,1300],{"class":411},"'users'",[401,1302,1222],{"class":537},[401,1304,1225],{"class":524},[401,1306,1307],{"class":537}," (",[401,1309,1310],{"class":415},"Blueprint",[401,1312,1313],{"class":537}," $table) {\n",[401,1315,1316],{"class":403,"line":755},[401,1317,1318],{"class":524},"    ...\n",[401,1320,1321,1324,1326,1329,1331,1334,1336,1338],{"class":403,"line":763},[401,1322,1323],{"class":537},"    $table",[401,1325,585],{"class":524},[401,1327,1328],{"class":407},"addColumn",[401,1330,591],{"class":537},[401,1332,1333],{"class":411},"'country'",[401,1335,1222],{"class":537},[401,1337,1333],{"class":411},[401,1339,1281],{"class":537},[401,1341,1342],{"class":403,"line":774},[401,1343,1244],{"class":537},[11,1345,1346,1347,1350,1351,1354],{},"Without the ",[15,1348,1349],{},"Grammar::macro",", Laravel would not recognize the ",[15,1352,1353],{},"country"," column type.",[327,1356,1358],{"id":1357},"replacing-virtual-json-columns","Replacing virtual JSON columns",[11,1360,1361,1362,1365],{},"MySQL supported ",[431,1363,1364],{},"virtual columns derived from JSON fields",". PostgreSQL does not support virtual columns in the same way.",[11,1367,1368,1369,296],{},"ChatGPT actually suggested a simple alternative: ",[431,1370,1371],{},"use an index on the JSON expression instead",[11,1373,1374,1375,1378,1379,1382],{},"For example, if ",[15,1376,1377],{},"settings"," is a JSON column in the ",[15,1380,1381],{},"users"," table:",[393,1384,1388],{"className":1385,"code":1386,"language":1387,"meta":221,"style":221},"language-sql shiki shiki-themes github-light github-dark","CREATE INDEX ON users((settings->>'setting1'));\n","sql",[15,1389,1390],{"__ignoreMap":221},[401,1391,1392,1394,1397,1400,1403,1406,1409],{"class":403,"line":404},[401,1393,1266],{"class":524},[401,1395,1396],{"class":524}," INDEX",[401,1398,1399],{"class":407}," ON",[401,1401,1402],{"class":537}," users((settings",[401,1404,1405],{"class":524},"->>",[401,1407,1408],{"class":411},"'setting1'",[401,1410,1411],{"class":537},"));\n",[11,1413,1414],{},"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.",[327,1416,1418],{"id":1417},"preparing-tables-for-partitioning","Preparing tables for partitioning",[11,1420,1421,1422,296],{},"One of the tables needed to be ",[431,1423,1424],{},"partitioned by date",[11,1426,1427,1428,296],{},"In PostgreSQL, tables must be created ",[431,1429,1430],{},"with partitioning defined from the start",[11,1432,1198],{},[393,1434,1436],{"className":1385,"code":1435,"language":1387,"meta":221,"style":221},"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",[15,1437,1438,1451,1462,1466,1479,1487],{"__ignoreMap":221},[401,1439,1440,1442,1445,1448],{"class":403,"line":404},[401,1441,1266],{"class":524},[401,1443,1444],{"class":524}," TABLE",[401,1446,1447],{"class":407}," actions",[401,1449,1450],{"class":537}," (\n",[401,1452,1453,1456,1459],{"class":403,"line":222},[401,1454,1455],{"class":537},"    id ",[401,1457,1458],{"class":524},"SERIAL",[401,1460,1461],{"class":537},",\n",[401,1463,1464],{"class":403,"line":708},[401,1465,1318],{"class":537},[401,1467,1468,1471,1474,1477],{"class":403,"line":716},[401,1469,1470],{"class":537},"    created_at ",[401,1472,1473],{"class":524},"TIMESTAMP",[401,1475,1476],{"class":524}," NOT NULL",[401,1478,1461],{"class":537},[401,1480,1481,1484],{"class":403,"line":727},[401,1482,1483],{"class":524},"    PRIMARY KEY",[401,1485,1486],{"class":537}," (id, created_at)\n",[401,1488,1489,1492,1495,1498,1501],{"class":403,"line":735},[401,1490,1491],{"class":537},") ",[401,1493,1494],{"class":524},"PARTITION",[401,1496,1497],{"class":524}," BY",[401,1499,1500],{"class":524}," RANGE",[401,1502,1503],{"class":537}," (created_at);\n",[327,1505,1507],{"id":1506},"enabling-required-extensions","Enabling required extensions",[11,1509,1510,1511,1514,1515,1518],{},"One of the main motivations for moving to PostgreSQL was the ability to use ",[431,1512,1513],{},"vector data types",", so I added the ",[15,1516,1517],{},"vector"," extension during migration:",[393,1520,1522],{"className":574,"code":1521,"language":489,"meta":221,"style":221},"DB::statement(\"CREATE EXTENSION IF NOT EXISTS vector;\");\n",[15,1523,1524],{"__ignoreMap":221},[401,1525,1526,1528,1530,1532,1534,1536,1538,1541,1544,1547,1550,1553,1556],{"class":403,"line":404},[401,1527,1253],{"class":415},[401,1529,1211],{"class":524},[401,1531,1258],{"class":407},[401,1533,591],{"class":537},[401,1535,1263],{"class":411},[401,1537,1266],{"class":524},[401,1539,1540],{"class":411}," EXTENSION ",[401,1542,1543],{"class":524},"IF",[401,1545,1546],{"class":524}," NOT",[401,1548,1549],{"class":524}," EXISTS",[401,1551,1552],{"class":524}," vector",[401,1554,1555],{"class":411},";\"",[401,1557,1281],{"class":537},[11,1559,1560,1561,1564],{},"This command must be executed ",[431,1562,1563],{},"for every new database"," where the extension is required.",[11,1566,1567,1568,1571,1572,1575],{},"We also relied on ",[431,1569,1570],{},"geospatial queries",", so we enabled ",[431,1573,1574],{},"PostGIS"," as well:",[393,1577,1579],{"className":574,"code":1578,"language":489,"meta":221,"style":221},"DB::statement(\"CREATE EXTENSION IF NOT EXISTS postgis;\");\n",[15,1580,1581],{"__ignoreMap":221},[401,1582,1583,1585,1587,1589,1591,1593,1595,1597,1599,1601,1603,1606],{"class":403,"line":404},[401,1584,1253],{"class":415},[401,1586,1211],{"class":524},[401,1588,1258],{"class":407},[401,1590,591],{"class":537},[401,1592,1263],{"class":411},[401,1594,1266],{"class":524},[401,1596,1540],{"class":411},[401,1598,1543],{"class":524},[401,1600,1546],{"class":524},[401,1602,1549],{"class":524},[401,1604,1605],{"class":411}," postgis;\"",[401,1607,1281],{"class":537},[327,1609,1611],{"id":1610},"local-development-environment","Local development environment",[11,1613,1614],{},"Speaking of extensions, they must first be installed in the database environment.",[11,1616,1617,1618,1621],{},"AWS RDS comes with many extensions pre-installed. However, for ",[431,1619,1620],{},"local development"," we usually rely on Docker images.",[11,1623,1624,1625,1628,1629,1632],{},"Finding images with ",[431,1626,1627],{},"Postgres + pgvector"," or ",[431,1630,1631],{},"Postgres + PostGIS"," separately is easy.",[11,1634,1635,1636,1639],{},"Finding an image that contains ",[431,1637,1638],{},"both extensions and works on ARM64"," is not.",[11,1641,1642],{},"So I had to build one myself:",[393,1644,1648],{"className":1645,"code":1646,"language":1647,"meta":221,"style":221},"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",[15,1649,1650,1658,1662,1670,1674,1682,1687,1692,1697,1702,1707,1712,1717,1723,1729,1735,1741,1747,1753,1759,1765,1771,1777,1783,1789,1794,1802,1808,1814,1820,1826,1832,1838,1844,1850,1856,1862,1868,1874,1880],{"__ignoreMap":221},[401,1651,1652,1655],{"class":403,"line":404},[401,1653,1654],{"class":524},"FROM",[401,1656,1657],{"class":537}," pgvector\u002Fpgvector:0.8.0-pg16\n",[401,1659,1660],{"class":403,"line":222},[401,1661,909],{"emptyLinePlaceholder":228},[401,1663,1664,1667],{"class":403,"line":708},[401,1665,1666],{"class":524},"ENV",[401,1668,1669],{"class":537}," POSTGIS_VERSION=3.5.0\n",[401,1671,1672],{"class":403,"line":716},[401,1673,909],{"emptyLinePlaceholder":228},[401,1675,1676,1679],{"class":403,"line":727},[401,1677,1678],{"class":524},"RUN",[401,1680,1681],{"class":537}," apt-get update && apt-get install -y \\\n",[401,1683,1684],{"class":403,"line":735},[401,1685,1686],{"class":537},"    build-essential \\\n",[401,1688,1689],{"class":403,"line":744},[401,1690,1691],{"class":537},"    cmake \\\n",[401,1693,1694],{"class":403,"line":755},[401,1695,1696],{"class":537},"    git \\\n",[401,1698,1699],{"class":403,"line":763},[401,1700,1701],{"class":537},"    wget \\\n",[401,1703,1704],{"class":403,"line":774},[401,1705,1706],{"class":537},"    pkg-config \\\n",[401,1708,1709],{"class":403,"line":782},[401,1710,1711],{"class":537},"    postgresql-server-dev-16 \\\n",[401,1713,1714],{"class":403,"line":990},[401,1715,1716],{"class":537},"    libxml2-dev \\\n",[401,1718,1720],{"class":403,"line":1719},13,[401,1721,1722],{"class":537},"    libgeos-dev \\\n",[401,1724,1726],{"class":403,"line":1725},14,[401,1727,1728],{"class":537},"    libproj-dev \\\n",[401,1730,1732],{"class":403,"line":1731},15,[401,1733,1734],{"class":537},"    libgdal-dev \\\n",[401,1736,1738],{"class":403,"line":1737},16,[401,1739,1740],{"class":537},"    libjson-c-dev \\\n",[401,1742,1744],{"class":403,"line":1743},17,[401,1745,1746],{"class":537},"    libprotobuf-c-dev \\\n",[401,1748,1750],{"class":403,"line":1749},18,[401,1751,1752],{"class":537},"    protobuf-c-compiler \\\n",[401,1754,1756],{"class":403,"line":1755},19,[401,1757,1758],{"class":537},"    libssl-dev \\\n",[401,1760,1762],{"class":403,"line":1761},20,[401,1763,1764],{"class":537},"    libcurl4-openssl-dev \\\n",[401,1766,1768],{"class":403,"line":1767},21,[401,1769,1770],{"class":537},"    libtiff-dev \\\n",[401,1772,1774],{"class":403,"line":1773},22,[401,1775,1776],{"class":537},"    libsqlite3-dev \\\n",[401,1778,1780],{"class":403,"line":1779},23,[401,1781,1782],{"class":537},"    sqlite3 \\\n",[401,1784,1786],{"class":403,"line":1785},24,[401,1787,1788],{"class":537},"    && rm -rf \u002Fvar\u002Flib\u002Fapt\u002Flists\u002F*\n",[401,1790,1792],{"class":403,"line":1791},25,[401,1793,909],{"emptyLinePlaceholder":228},[401,1795,1797,1799],{"class":403,"line":1796},26,[401,1798,1678],{"class":524},[401,1800,1801],{"class":537}," cd \u002Ftmp && \\\n",[401,1803,1805],{"class":403,"line":1804},27,[401,1806,1807],{"class":537},"    wget https:\u002F\u002Fdownload.osgeo.org\u002Fpostgis\u002Fsource\u002Fpostgis-${POSTGIS_VERSION}.tar.gz && \\\n",[401,1809,1811],{"class":403,"line":1810},28,[401,1812,1813],{"class":537},"    tar -xzf postgis-${POSTGIS_VERSION}.tar.gz && \\\n",[401,1815,1817],{"class":403,"line":1816},29,[401,1818,1819],{"class":537},"    cd postgis-${POSTGIS_VERSION} && \\\n",[401,1821,1823],{"class":403,"line":1822},30,[401,1824,1825],{"class":537},"    .\u002Fconfigure \\\n",[401,1827,1829],{"class":403,"line":1828},31,[401,1830,1831],{"class":537},"    --with-pgconfig=\u002Fusr\u002Fbin\u002Fpg_config \\\n",[401,1833,1835],{"class":403,"line":1834},32,[401,1836,1837],{"class":537},"    --with-geosconfig=\u002Fusr\u002Fbin\u002Fgeos-config \\\n",[401,1839,1841],{"class":403,"line":1840},33,[401,1842,1843],{"class":537},"    --with-projdir=\u002Fusr \\\n",[401,1845,1847],{"class":403,"line":1846},34,[401,1848,1849],{"class":537},"    --with-gdalconfig=\u002Fusr\u002Fbin\u002Fgdal-config \\\n",[401,1851,1853],{"class":403,"line":1852},35,[401,1854,1855],{"class":537},"    --with-jsondir=\u002Fusr \\\n",[401,1857,1859],{"class":403,"line":1858},36,[401,1860,1861],{"class":537},"    --with-protobufdir=\u002Fusr && \\\n",[401,1863,1865],{"class":403,"line":1864},37,[401,1866,1867],{"class":537},"    make && \\\n",[401,1869,1871],{"class":403,"line":1870},38,[401,1872,1873],{"class":537},"    make install && \\\n",[401,1875,1877],{"class":403,"line":1876},39,[401,1878,1879],{"class":537},"    cd \u002F && \\\n",[401,1881,1883],{"class":403,"line":1882},40,[401,1884,1885],{"class":537},"    rm -rf \u002Ftmp\u002Fpostgis-*\n",[327,1887,1889],{"id":1888},"migration-process","Migration process",[11,1891,1892,1893,849,1896,296],{},"After testing the process locally, I ran it in ",[431,1894,1895],{},"staging",[431,1897,1898],{},"production",[11,1900,1901],{},"The migration flow looked like this:",[150,1903,1904,1910,1916],{},[34,1905,1906,1907],{},"Create a new ",[431,1908,1909],{},"AWS RDS instance",[34,1911,1912,1913],{},"Connect using ",[15,1914,1915],{},"psql",[34,1917,1918,1919],{},"Enable the required extensions:",[31,1920,1921,1926],{},[34,1922,1923],{},[15,1924,1925],{},"aws_commons",[34,1927,1928],{},[15,1929,1930],{},"aws_s3",[11,1932,1933,1934,1937],{},"These allow importing CSV files from ",[431,1935,1936],{},"S3",", since RDS does not provide filesystem access.",[150,1939,1940,1947,1953,1956],{"start":716},[34,1941,1942,1943,1946],{},"Create a ",[431,1944,1945],{},"VPC Endpoint"," so the database can access S3.",[34,1948,1949,1950,597],{},"Update the Laravel database configuration (",[15,1951,1952],{},"config\u002Fdatabase.php",[34,1954,1955],{},"Deploy the code with the new migration and apply the schema.",[34,1957,1958],{},"Stop the application to prevent writes during migration:",[393,1960,1962],{"className":395,"code":1961,"language":397,"meta":221,"style":221},"php artisan down\n",[15,1963,1964],{"__ignoreMap":221},[401,1965,1966,1968,1970],{"class":403,"line":404},[401,1967,489],{"class":407},[401,1969,492],{"class":411},[401,1971,1972],{"class":411}," down\n",[150,1974,1975],{"start":755},[34,1976,1977],{},"Export data from MySQL to CSV:",[393,1979,1981],{"className":395,"code":1980,"language":397,"meta":221,"style":221},"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",[15,1982,1983,2006,2013,2020,2027,2037],{"__ignoreMap":221},[401,1984,1985,1988,1991,1994,1997,2000,2003],{"class":403,"line":404},[401,1986,1987],{"class":407},"mysqldump",[401,1989,1990],{"class":415}," --port=3308",[401,1992,1993],{"class":415}," -u",[401,1995,1996],{"class":411}," root",[401,1998,1999],{"class":415}," -psecret",[401,2001,2002],{"class":411}," db-name",[401,2004,2005],{"class":415}," \\\n",[401,2007,2008,2011],{"class":403,"line":222},[401,2009,2010],{"class":415},"  --no-create-info",[401,2012,2005],{"class":415},[401,2014,2015,2018],{"class":403,"line":708},[401,2016,2017],{"class":415},"  --fields-terminated-by=,",[401,2019,2005],{"class":415},[401,2021,2022,2025],{"class":403,"line":716},[401,2023,2024],{"class":415},"  --tab=\u002Fvar\u002Flib\u002Fmysql-files",[401,2026,2005],{"class":415},[401,2028,2029,2032,2035],{"class":403,"line":727},[401,2030,2031],{"class":415},"  --fields-enclosed-by=",[401,2033,2034],{"class":411},"'\\\"'",[401,2036,2005],{"class":415},[401,2038,2039,2042],{"class":403,"line":735},[401,2040,2041],{"class":415},"  --lines-terminated-by=",[401,2043,2044],{"class":411},"'\\n'\n",[11,2046,2047],{},"This generates files for each table:",[393,2049,2054],{"className":2050,"code":2052,"language":2053},[2051],"language-text","actions.sql  actions.txt\nusers.sql    users.txt\n","text",[15,2055,2052],{"__ignoreMap":221},[11,2057,2058],{},"Key flags:",[31,2060,2061,2067,2073,2079,2085],{},[34,2062,2063,2066],{},[15,2064,2065],{},"--no-create-info"," → skip schema generation",[34,2068,2069,2072],{},[15,2070,2071],{},"--fields-terminated-by=,"," → comma separator",[34,2074,2075,2078],{},[15,2076,2077],{},"--tab=\u002Fvar\u002Flib\u002Fmysql-files"," → required export directory",[34,2080,2081,2084],{},[15,2082,2083],{},"--fields-enclosed-by='\"'"," → format expected by PostgreSQL COPY",[34,2086,2087,2090],{},[15,2088,2089],{},"--lines-terminated-by='\\n'"," → newline rows",[150,2092,2093,2108],{"start":763},[34,2094,2095,2096,2099,2100,2103,2104,2107],{},"Upload the ",[15,2097,2098],{},".txt"," files to an ",[431,2101,2102],{},"S3 bucket",".\nIf you generated the files inside a container, ",[15,2105,2106],{},"docker cp"," helps extract them.",[34,2109,2110],{},"Import each table using:",[393,2112,2114],{"className":1385,"code":2113,"language":1387,"meta":221,"style":221},"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",[15,2115,2116,2132,2139,2146,2153,2160,2167,2174,2181,2188,2193],{"__ignoreMap":221},[401,2117,2118,2121,2124,2126,2129],{"class":403,"line":404},[401,2119,2120],{"class":524},"SELECT",[401,2122,2123],{"class":415}," aws_s3",[401,2125,296],{"class":537},[401,2127,2128],{"class":415},"table_import_from_s3",[401,2130,2131],{"class":537},"(\n",[401,2133,2134,2137],{"class":403,"line":222},[401,2135,2136],{"class":411},"  'users'",[401,2138,1461],{"class":537},[401,2140,2141,2144],{"class":403,"line":708},[401,2142,2143],{"class":411},"  ''",[401,2145,1461],{"class":537},[401,2147,2148,2151],{"class":403,"line":716},[401,2149,2150],{"class":411},"  '(format csv, header false, null ''\\N'', escape ''\\'')'",[401,2152,1461],{"class":537},[401,2154,2155,2158],{"class":403,"line":727},[401,2156,2157],{"class":411},"  'db-name'",[401,2159,1461],{"class":537},[401,2161,2162,2165],{"class":403,"line":735},[401,2163,2164],{"class":411},"  '\u002Fdumps\u002Fusers.txt'",[401,2166,1461],{"class":537},[401,2168,2169,2172],{"class":403,"line":744},[401,2170,2171],{"class":411},"  'your-aws-region'",[401,2173,1461],{"class":537},[401,2175,2176,2179],{"class":403,"line":755},[401,2177,2178],{"class":411},"  'AWS_ACCESS_KEY'",[401,2180,1461],{"class":537},[401,2182,2183,2186],{"class":403,"line":763},[401,2184,2185],{"class":411},"  'AWS_SECRET_KEY'",[401,2187,1461],{"class":537},[401,2189,2190],{"class":403,"line":774},[401,2191,2192],{"class":411},"  ''\n",[401,2194,2195],{"class":403,"line":782},[401,2196,1281],{"class":537},[11,2198,2199],{},"Repeat this command for each table.",[150,2201,2202],{"start":782},[34,2203,2204],{},"Restart the application:",[393,2206,2208],{"className":395,"code":2207,"language":397,"meta":221,"style":221},"php artisan up\n",[15,2209,2210],{"__ignoreMap":221},[401,2211,2212,2214,2216],{"class":403,"line":404},[401,2213,489],{"class":407},[401,2215,492],{"class":411},[401,2217,2218],{"class":411}," up\n",[327,2220,2222],{"id":2221},"one-more-important-detail","One more important detail",[11,2224,2225],{},"When creating the PostgreSQL schema, there are two approaches:",[2227,2228,2230],"h3",{"id":2229},"option-1-no-foreign-keys-initially","Option 1 — No foreign keys initially",[11,2232,2233,2234,2237],{},"Create tables ",[431,2235,2236],{},"without foreign keys"," so the import order doesn't matter.\nThen apply a second migration to add constraints afterward.",[2227,2239,2241],{"id":2240},"option-2-foreign-keys-from-the-start","Option 2 — Foreign keys from the start",[11,2243,2244,2245,296],{},"Create tables with foreign keys immediately.\nIn this case, the ",[431,2246,2247],{},"table import order becomes important",[327,2249,2251],{"id":2250},"final-thoughts","Final thoughts",[11,2253,2254,2255,2258],{},"For large production databases, a ",[431,2256,2257],{},"schema-first approach with CSV bulk import"," turned out to be much more reliable and faster than using automated migration tools.",[11,2260,2261],{},"It also gave us full control over:",[31,2263,2264,2267,2270,2273,2276],{},[34,2265,2266],{},"data types",[34,2268,2269],{},"indexing strategy",[34,2271,2272],{},"partitioning",[34,2274,2275],{},"extensions",[34,2277,2278],{},"schema cleanup",[456,2280,2281],{},"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 .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}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);}",{"title":221,"searchDepth":222,"depth":222,"links":2283},[2284,2285,2286,2287,2288,2289,2290,2291,2292,2293,2297],{"id":1040,"depth":222,"text":1041},{"id":1090,"depth":222,"text":1091},{"id":1123,"depth":222,"text":1124},{"id":1184,"depth":222,"text":1185},{"id":1357,"depth":222,"text":1358},{"id":1417,"depth":222,"text":1418},{"id":1506,"depth":222,"text":1507},{"id":1610,"depth":222,"text":1611},{"id":1888,"depth":222,"text":1889},{"id":2221,"depth":222,"text":2222,"children":2294},[2295,2296],{"id":2229,"depth":708,"text":2230},{"id":2240,"depth":708,"text":2241},{"id":2250,"depth":222,"text":2251},"Experience I gained while migrating a large MySQL database to PostgreSQL on AWS RDS.",{"date":2300},"2025-09-11","\u002Fen\u002F2025-09-11-mysql-to-pg",{"title":1035,"description":2298},"en\u002F2025-09-11-mysql-to-pg","_VxTKfwG5IXwvxFt997QK895VM5qGOwy7UdBdBbnXgo",{"id":2306,"title":2307,"body":2308,"description":3025,"extension":225,"meta":3026,"navigation":228,"path":3028,"seo":3029,"stem":3030,"__hash__":3031},"content\u002Fen\u002F2025-04-25-env-files.md",".env files",{"type":8,"value":2309,"toc":3014},[2310,2314,2317,2320,2323,2325,2329,2335,2342,2357,2360,2366,2369,2372,2374,2378,2381,2384,2390,2396,2399,2409,2412,2419,2422,2424,2428,2431,2441,2452,2455,2458,2460,2464,2473,2483,2486,2510,2513,2519,2522,2524,2531,2537,2540,2549,2552,2554,2558,2567,2979,2985,2988,2990,2993,2996,2999,3008,3011],[327,2311,2313],{"id":2312},"managing-secrets-without-the-pain","Managing Secrets Without the Pain",[11,2315,2316],{},"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.",[11,2318,2319],{},"Over time I tried several approaches. Some worked… until they didn’t.",[11,2321,2322],{},"This post is about how I used to manage secrets, the problems that came with it, and how I recently simplified the whole process.",[444,2324],{},[327,2326,2328],{"id":2327},"the-old-approach-local-encryption","The Old Approach: Local Encryption",[11,2330,2331,2332,296],{},"For years my go-to solution was ",[431,2333,2334],{},"local encryption",[11,2336,2337,2338,2341],{},"I relied on good old ",[431,2339,2340],{},"openssl",", which is available on almost any system. The idea was straightforward:",[150,2343,2344,2347,2354],{},[34,2345,2346],{},"Keep a keyfile somewhere safe (in my case, usually a hidden Google Doc).",[34,2348,2349,2350,2353],{},"Encrypt ",[15,2351,2352],{},".env"," files using that key.",[34,2355,2356],{},"Store the encrypted files in the repository.",[11,2358,2359],{},"The resulting files would look something like this:",[393,2361,2364],{"className":2362,"code":2363,"language":2053},[2051],".env.dev.enc\n.env.stage.enc\n.env.prod.enc\n",[15,2365,2363],{"__ignoreMap":221},[11,2367,2368],{},"When needed, I would decrypt them locally, update variables, and encrypt them again.",[11,2370,2371],{},"Simple enough — at least in theory.",[444,2373],{},[327,2375,2377],{"id":2376},"the-problem-with-this-workflow","The Problem With This Workflow",[11,2379,2380],{},"In practice, things became painful pretty quickly.",[11,2382,2383],{},"Every small change required going through the full cycle:",[393,2385,2388],{"className":2386,"code":2387,"language":2053},[2051],"decrypt → edit → encrypt\n",[15,2389,2387],{"__ignoreMap":221},[11,2391,2392,2393,296],{},"And this had to be repeated ",[431,2394,2395],{},"for every environment",[11,2397,2398],{},"After doing this enough times, it became tempting to take shortcuts.",[11,2400,2401,2402,2408],{},"More often than I’d like to admit, I ended up using a ",[431,2403,2404,2405,2407],{},"single shared ",[15,2406,2352],{}," file"," across multiple applications just to make life easier (though who was I really fooling?).",[11,2410,2411],{},"The consequences were predictable.",[11,2413,2414,2415,2418],{},"Sometimes backend environment variables would end up sitting right next to frontend ones — including things like ",[431,2416,2417],{},"database connection credentials"," 🤪.",[11,2420,2421],{},"Not exactly ideal.",[444,2423],{},[327,2425,2427],{"id":2426},"another-issue-separate-secret-storage","Another Issue: Separate Secret Storage",[11,2429,2430],{},"There was also a structural problem.",[11,2432,2433,2434,2436,2437,2440],{},"Encrypted ",[15,2435,2352],{}," files usually lived in a ",[431,2438,2439],{},"separate repository",", which meant:",[31,2442,2443,2446,2449],{},[34,2444,2445],{},"secrets were updated independently",[34,2447,2448],{},"application changes and secret changes were not synchronized",[34,2450,2451],{},"mistakes became easier to make",[11,2453,2454],{},"Any extra moving part in a deployment process increases the chance that something goes wrong.",[11,2456,2457],{},"And this definitely added a few more.",[444,2459],{},[327,2461,2463],{"id":2462},"discovering-doppler","Discovering Doppler",[11,2465,2466,2467,2472],{},"Recently I came across ",[375,2468,2471],{"href":2469,"rel":2470},"https:\u002F\u002Fwww.doppler.com\u002F",[379],"Doppler",".\nIt turned out to be a really nice way to manage secrets.",[11,2474,2475,2476,2478,2479,2482],{},"Instead of juggling encrypted ",[15,2477,2352],{}," files, secrets are stored and managed through a ",[431,2480,2481],{},"clean web interface",", and injected directly when running commands.",[11,2484,2485],{},"For example:",[393,2487,2489],{"className":395,"code":2488,"language":397,"meta":221,"style":221},"doppler run -- npm run build\n",[15,2490,2491],{"__ignoreMap":221},[401,2492,2493,2496,2499,2502,2505,2507],{"class":403,"line":404},[401,2494,2495],{"class":407},"doppler",[401,2497,2498],{"class":411}," run",[401,2500,2501],{"class":415}," --",[401,2503,2504],{"class":411}," npm",[401,2506,2498],{"class":411},[401,2508,2509],{"class":411}," build\n",[11,2511,2512],{},"The application simply runs with the correct configuration profile.",[11,2514,2515,2516,2518],{},"No ",[15,2517,2352],{}," files required.",[11,2520,2521],{},"Even better, Doppler provides integrations with most modern deployment tools.",[444,2523],{},[327,2525,2527,2528,2530],{"id":2526},"why-i-still-keep-env-files","Why I Still Keep ",[15,2529,2352],{}," Files",[11,2532,2533,2534,296],{},"That said, I don’t love being ",[431,2535,2536],{},"completely dependent on a single tool",[11,2538,2539],{},"If the tool disappears tomorrow, I still want my projects to work.",[11,2541,2542,2543,2545,2546,296],{},"So instead of abandoning ",[15,2544,2352],{}," files entirely, I decided to ",[431,2547,2548],{},"generate them automatically using Doppler",[11,2550,2551],{},"This keeps things portable while still benefiting from centralized secret management.",[444,2553],{},[327,2555,2557],{"id":2556},"automating-setup-with-taskfile","Automating Setup With Taskfile",[11,2559,2560,2561,2566],{},"To make things smoother, I created a small ",[375,2562,2565],{"href":2563,"rel":2564},"https:\u002F\u002Ftaskfile.dev\u002F",[379],"Taskfile"," for initializing projects with the correct environment variables:",[393,2568,2570],{"className":682,"code":2569,"language":684,"meta":221,"style":221},"# 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",[15,2571,2572,2577,2587,2591,2598,2605,2612,2620,2624,2631,2637,2644,2649,2656,2663,2673,2683,2693,2699,2706,2710,2717,2723,2734,2773,2777,2784,2790,2800,2832,2836,2843,2849,2859,2891,2895,2902,2926,2930,2937,2943,2955,2967],{"__ignoreMap":221},[401,2573,2574],{"class":403,"line":404},[401,2575,2576],{"class":895},"# yaml-language-server: $schema=https:\u002F\u002Ftaskfile.dev\u002Fschema.json\n",[401,2578,2579,2582,2584],{"class":403,"line":222},[401,2580,2581],{"class":691},"version",[401,2583,173],{"class":537},[401,2585,2586],{"class":411},"\"3\"\n",[401,2588,2589],{"class":403,"line":708},[401,2590,909],{"emptyLinePlaceholder":228},[401,2592,2593,2596],{"class":403,"line":716},[401,2594,2595],{"class":691},"tasks",[401,2597,695],{"class":537},[401,2599,2600,2603],{"class":403,"line":727},[401,2601,2602],{"class":691},"  install",[401,2604,695],{"class":537},[401,2606,2607,2610],{"class":403,"line":735},[401,2608,2609],{"class":691},"    cmds",[401,2611,695],{"class":537},[401,2613,2614,2617],{"class":403,"line":744},[401,2615,2616],{"class":537},"      - ",[401,2618,2619],{"class":411},"curl -Ls --tlsv1.3 --proto \"=https\" https:\u002F\u002Fcli.doppler.com\u002Finstall.sh | sh\n",[401,2621,2622],{"class":403,"line":755},[401,2623,909],{"emptyLinePlaceholder":228},[401,2625,2626,2629],{"class":403,"line":763},[401,2627,2628],{"class":691},"  login",[401,2630,695],{"class":537},[401,2632,2633,2635],{"class":403,"line":774},[401,2634,2609],{"class":691},[401,2636,695],{"class":537},[401,2638,2639,2641],{"class":403,"line":782},[401,2640,2616],{"class":537},[401,2642,2643],{"class":411},"doppler login\n",[401,2645,2646],{"class":403,"line":990},[401,2647,2648],{"class":537},"  \n",[401,2650,2651,2654],{"class":403,"line":1719},[401,2652,2653],{"class":691},"  get-env",[401,2655,695],{"class":537},[401,2657,2658,2661],{"class":403,"line":1725},[401,2659,2660],{"class":691},"    vars",[401,2662,695],{"class":537},[401,2664,2665,2668,2670],{"class":403,"line":1731},[401,2666,2667],{"class":691},"      DOPPLER_PROJECT",[401,2669,173],{"class":537},[401,2671,2672],{"class":411},"'{{default \"help\" .DOPPLER_PROJECT}}'\n",[401,2674,2675,2678,2680],{"class":403,"line":1737},[401,2676,2677],{"class":691},"      DOPPLER_CONFIG",[401,2679,173],{"class":537},[401,2681,2682],{"class":411},"'{{default \"dev\" .DOPPLER_CONFIG}}'\n",[401,2684,2685,2688,2690],{"class":403,"line":1743},[401,2686,2687],{"class":691},"      FILEPATH",[401,2689,173],{"class":537},[401,2691,2692],{"class":411},"'{{default \"\" .FILEPATH}}'\n",[401,2694,2695,2697],{"class":403,"line":1749},[401,2696,2609],{"class":691},[401,2698,695],{"class":537},[401,2700,2701,2703],{"class":403,"line":1755},[401,2702,2616],{"class":537},[401,2704,2705],{"class":411},"doppler secrets download --project {{.DOPPLER_PROJECT}} --config {{.DOPPLER_CONFIG}} --no-file --format env > {{.FILEPATH}}\n",[401,2707,2708],{"class":403,"line":1761},[401,2709,2648],{"class":537},[401,2711,2712,2715],{"class":403,"line":1767},[401,2713,2714],{"class":691},"  setup-infra-secrets",[401,2716,695],{"class":537},[401,2718,2719,2721],{"class":403,"line":1773},[401,2720,2609],{"class":691},[401,2722,695],{"class":537},[401,2724,2725,2727,2729,2731],{"class":403,"line":1779},[401,2726,2616],{"class":537},[401,2728,329],{"class":691},[401,2730,173],{"class":537},[401,2732,2733],{"class":411},"get-env\n",[401,2735,2736,2739,2742,2745,2747,2750,2752,2755,2757,2760,2762,2765,2767,2770],{"class":403,"line":1785},[401,2737,2738],{"class":691},"        vars",[401,2740,2741],{"class":537},": { ",[401,2743,2744],{"class":691},"DOPPLER_PROJECT",[401,2746,173],{"class":537},[401,2748,2749],{"class":411},"\"infra\"",[401,2751,1222],{"class":537},[401,2753,2754],{"class":691},"DOPPLER_CONFIG",[401,2756,173],{"class":537},[401,2758,2759],{"class":411},"\"{{.DOPPLER_CONFIG}}\"",[401,2761,1222],{"class":537},[401,2763,2764],{"class":691},"FILEPATH",[401,2766,173],{"class":537},[401,2768,2769],{"class":411},"\".env\"",[401,2771,2772],{"class":537}," }\n",[401,2774,2775],{"class":403,"line":1791},[401,2776,909],{"emptyLinePlaceholder":228},[401,2778,2779,2782],{"class":403,"line":1796},[401,2780,2781],{"class":691},"  setup-frontend-secrets",[401,2783,695],{"class":537},[401,2785,2786,2788],{"class":403,"line":1804},[401,2787,2609],{"class":691},[401,2789,695],{"class":537},[401,2791,2792,2794,2796,2798],{"class":403,"line":1810},[401,2793,2616],{"class":537},[401,2795,329],{"class":691},[401,2797,173],{"class":537},[401,2799,2733],{"class":411},[401,2801,2802,2804,2806,2808,2810,2813,2815,2817,2819,2821,2823,2825,2827,2830],{"class":403,"line":1816},[401,2803,2738],{"class":691},[401,2805,2741],{"class":537},[401,2807,2744],{"class":691},[401,2809,173],{"class":537},[401,2811,2812],{"class":411},"\"frontend\"",[401,2814,1222],{"class":537},[401,2816,2754],{"class":691},[401,2818,173],{"class":537},[401,2820,2759],{"class":411},[401,2822,1222],{"class":537},[401,2824,2764],{"class":691},[401,2826,173],{"class":537},[401,2828,2829],{"class":411},"\"apps\u002Ffrontend\u002F.env\"",[401,2831,2772],{"class":537},[401,2833,2834],{"class":403,"line":1822},[401,2835,2648],{"class":537},[401,2837,2838,2841],{"class":403,"line":1828},[401,2839,2840],{"class":691},"  setup-backend-secrets",[401,2842,695],{"class":537},[401,2844,2845,2847],{"class":403,"line":1834},[401,2846,2609],{"class":691},[401,2848,695],{"class":537},[401,2850,2851,2853,2855,2857],{"class":403,"line":1840},[401,2852,2616],{"class":537},[401,2854,329],{"class":691},[401,2856,173],{"class":537},[401,2858,2733],{"class":411},[401,2860,2861,2863,2865,2867,2869,2872,2874,2876,2878,2880,2882,2884,2886,2889],{"class":403,"line":1846},[401,2862,2738],{"class":691},[401,2864,2741],{"class":537},[401,2866,2744],{"class":691},[401,2868,173],{"class":537},[401,2870,2871],{"class":411},"\"backend\"",[401,2873,1222],{"class":537},[401,2875,2754],{"class":691},[401,2877,173],{"class":537},[401,2879,2759],{"class":411},[401,2881,1222],{"class":537},[401,2883,2764],{"class":691},[401,2885,173],{"class":537},[401,2887,2888],{"class":411},"\"apps\u002Fbackend\u002F.env\"",[401,2890,2772],{"class":537},[401,2892,2893],{"class":403,"line":1852},[401,2894,909],{"emptyLinePlaceholder":228},[401,2896,2897,2900],{"class":403,"line":1858},[401,2898,2899],{"class":691},"  setup-secrets",[401,2901,695],{"class":537},[401,2903,2904,2907,2910,2913,2915,2918,2920,2923],{"class":403,"line":1864},[401,2905,2906],{"class":691},"    deps",[401,2908,2909],{"class":537},": [",[401,2911,2912],{"class":411},"setup-frontend-secrets",[401,2914,1222],{"class":537},[401,2916,2917],{"class":411},"setup-infra-secrets",[401,2919,1222],{"class":537},[401,2921,2922],{"class":411},"setup-backend-secrets",[401,2924,2925],{"class":537},"]\n",[401,2927,2928],{"class":403,"line":1870},[401,2929,909],{"emptyLinePlaceholder":228},[401,2931,2932,2935],{"class":403,"line":1876},[401,2933,2934],{"class":691},"  initial-setup",[401,2936,695],{"class":537},[401,2938,2939,2941],{"class":403,"line":1882},[401,2940,2609],{"class":691},[401,2942,695],{"class":537},[401,2944,2946,2948,2950,2952],{"class":403,"line":2945},41,[401,2947,2616],{"class":537},[401,2949,329],{"class":691},[401,2951,173],{"class":537},[401,2953,2954],{"class":411},"install\n",[401,2956,2958,2960,2962,2964],{"class":403,"line":2957},42,[401,2959,2616],{"class":537},[401,2961,329],{"class":691},[401,2963,173],{"class":537},[401,2965,2966],{"class":411},"login\n",[401,2968,2970,2972,2974,2976],{"class":403,"line":2969},43,[401,2971,2616],{"class":537},[401,2973,329],{"class":691},[401,2975,173],{"class":537},[401,2977,2978],{"class":411},"setup-secrets\n",[11,2980,2981,2982,2984],{},"With it, generating the ",[15,2983,2352],{}," file becomes part of the normal project setup.",[11,2986,2987],{},"No manual secret handling required.",[444,2989],{},[327,2991,2992],{"id":2250},"Final Thoughts",[11,2994,2995],{},"Managing secrets is one of those things that seems simple until it isn't.",[11,2997,2998],{},"My old setup worked for a while, but it created too much friction and too many opportunities for mistakes.",[11,3000,3001,3002,3004,3005,296],{},"Using Doppler — while still keeping ",[15,3003,2352],{}," files as a fallback — turned out to be a good balance between ",[431,3006,3007],{},"convenience and control",[11,3009,3010],{},"And honestly, anything that removes a few steps from secret management is already a win.",[456,3012,3013],{},"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":221,"searchDepth":222,"depth":222,"links":3015},[3016,3017,3018,3019,3020,3021,3023,3024],{"id":2312,"depth":222,"text":2313},{"id":2327,"depth":222,"text":2328},{"id":2376,"depth":222,"text":2377},{"id":2426,"depth":222,"text":2427},{"id":2462,"depth":222,"text":2463},{"id":2526,"depth":222,"text":3022},"Why I Still Keep .env Files",{"id":2556,"depth":222,"text":2557},{"id":2250,"depth":222,"text":2992},"My .env files setup",{"date":3027},"2025-04-25","\u002Fen\u002F2025-04-25-env-files",{"title":2307,"description":3025},"en\u002F2025-04-25-env-files","zShizKH59ohG6cPX4WAiyMESjvAsJHGz5RTwA7BNbAw",{"id":3033,"title":3034,"body":3035,"description":3324,"extension":225,"meta":3325,"navigation":228,"path":3327,"seo":3328,"stem":3329,"__hash__":3330},"content\u002Fen\u002F2025-03-25-zed.md","Zed Editor",{"type":8,"value":3036,"toc":3310},[3037,3051,3057,3061,3065,3068,3071,3082,3085,3089,3092,3095,3129,3143,3154,3157,3164,3168,3179,3182,3186,3193,3200,3204,3207,3246,3250,3254,3260,3266,3271,3275,3285,3288,3298,3300,3307],[11,3038,3039,3040,1307,3043,3047,3048,296],{},"A few days ago I tried ",[431,3041,3042],{},"Zed",[375,3044,3045],{"href":3045,"rel":3046},"https:\u002F\u002Fzed.dev\u002F",[379],") — a code editor written in ",[431,3049,3050],{},"Rust",[11,3052,3053,3054,296],{},"Here are some things that immediately stood out ",[431,3055,3056],{},"right out of the box",[327,3058,3060],{"id":3059},"built-in-features","Built-in features",[2227,3062,3064],{"id":3063},"collaborative-mode","Collaborative mode",[11,3066,3067],{},"Zed has a built-in collaborative mode. You can share your local codebase with a teammate and work together in the same environment.",[11,3069,3070],{},"It works surprisingly smoothly:",[31,3072,3073,3076,3079],{},[34,3074,3075],{},"you can talk via voice",[34,3077,3078],{},"follow what your teammate is doing",[34,3080,3081],{},"edit related parts of the code together in real time",[11,3083,3084],{},"It feels a bit like Google Docs, but for your local code.",[2227,3086,3088],{"id":3087},"ai-assistant","AI assistant",[11,3090,3091],{},"Zed includes an AI assistant that can be configured to use different modern LLMs.",[11,3093,3094],{},"I recommend watching the video on their homepage — it gives a good overview of how it works.",[11,3096,3097,3098,3101,3102,3106,3107,3110,3111,3114,3115,1222,3118,3121,3122,3125,3126,3128],{},"I experimented with running ",[431,3099,3100],{},"Ollama"," locally (",[375,3103,3104],{"href":3104,"rel":3105},"https:\u002F\u002Follama.com\u002F",[379],") to generate ",[431,3108,3109],{},"JSDoc"," for some of my functions. The only model that produced somewhat reasonable results was ",[431,3112,3113],{},"deepseek-coder",". I also tried ",[431,3116,3117],{},"gemma",[431,3119,3120],{},"llama",", and ",[431,3123,3124],{},"qwen"," (both with and without the ",[15,3127,15],{}," variants), but they didn’t generate anything particularly useful.",[11,3130,3131,3132,3135,3136,3139,3140,296],{},"Running LLMs locally turned out to be ",[431,3133,3134],{},"very resource-intensive",". It requires a lot of RAM, the CPU easily hits ",[431,3137,3138],{},"100%",", and responses can take ",[431,3141,3142],{},"several minutes",[11,3144,3145,3146,3149,3150,3153],{},"The main reason I tried running models locally is that many ",[431,3147,3148],{},"cloud AI providers don’t work in Russia",". I still haven’t gotten around to setting up a ",[431,3151,3152],{},"VPS with a VPN"," to use them instead.",[11,3155,3156],{},"Still, it was an interesting experiment.",[11,3158,3159,3160,3163],{},"Right now, Zed allows you to use its ",[431,3161,3162],{},"built-in assistant (based on Claude 3.5)"," for free because the feature is still new and currently being tested.",[2227,3165,3167],{"id":3166},"familiar-shortcuts","Familiar shortcuts",[11,3169,3170,3171,3174,3175,3178],{},"Zed ships with ",[431,3172,3173],{},"VSCode keybindings by default",", and you can also enable ",[431,3176,3177],{},"Vim mode"," if you prefer that workflow.",[11,3180,3181],{},"I’m not personally a Vim user, but I know many developers appreciate having that option.",[2227,3183,3185],{"id":3184},"multi-buffers-everywhere","Multi-buffers everywhere",[11,3187,3188,3189,3192],{},"One particularly interesting architectural idea: ",[431,3190,3191],{},"everything in Zed is implemented as text buffers",", including the built-in terminal.",[11,3194,3195,3196],{},"More details here:\n",[375,3197,3198],{"href":3198,"rel":3199},"https:\u002F\u002Fzed.dev\u002Ffeatures#multi-buffers",[379],[327,3201,3203],{"id":3202},"extensibility","Extensibility",[11,3205,3206],{},"Zed is surprisingly extensible.",[31,3208,3209,3216,3222,3236,3243],{},[34,3210,3211,3212],{},"Most programming languages are already supported:\n",[375,3213,3214],{"href":3214,"rel":3215},"https:\u002F\u002Fzed.dev\u002Fextensions",[379],[34,3217,3218,3219,296],{},"That’s not surprising considering the Zed team also created ",[431,3220,3221],{},"tree-sitter",[34,3223,3224,3225,849,3228,3231,3232,3235],{},"Extensions for ",[431,3226,3227],{},"Vue",[431,3229,3230],{},"PHP"," work great. Navigation works well — you can jump to definitions, find references, and use ",[431,3233,3234],{},"multi-buffers"," to edit related symbols across multiple files.",[34,3237,3238,3239,3242],{},"A nice bonus: for PHP you can reuse your ",[431,3240,3241],{},"Intelephense license"," if you already use it in VSCode. That makes working with PHP much more comfortable.",[34,3244,3245],{},"Themes, icons, and other customization options are also widely available.",[327,3247,3249],{"id":3248},"my-impressions","My impressions",[2227,3251,3253],{"id":3252},"pros","Pros",[11,3255,3256,3259],{},[431,3257,3258],{},"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.",[11,3261,3262,3265],{},[431,3263,3264],{},"Pair programming built in.","\nThis alone could be a big deal for teams.",[11,3267,3268],{},[431,3269,3270],{},"Highly configurable.",[2227,3272,3274],{"id":3273},"cons","Cons",[11,3276,3277,3280,3281],{},[431,3278,3279],{},"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",[375,3282,3283],{"href":3283,"rel":3284},"https:\u002F\u002Fgithub.com\u002Fzed-industries\u002Fzed\u002Fissues\u002F10454",[379],[11,3286,3287],{},"Maybe a good opportunity to contribute.",[11,3289,3290,3293,3294,3297],{},[431,3291,3292],{},"Git integration isn’t as deep as in VSCode (yet).","\nResolving merge conflicts is still easier in VSCode. But the basics like ",[431,3295,3296],{},"git blame"," are already there.",[327,3299,2251],{"id":2250},[11,3301,3302,3303,3306],{},"Zed already feels ",[431,3304,3305],{},"extremely fast and promising",". The core ideas behind the editor — performance, collaboration, and multi-buffers — are genuinely interesting.",[11,3308,3309],{},"It’s not a full replacement for VSCode for me yet, but it’s definitely something I’m keeping an eye on.",{"title":221,"searchDepth":222,"depth":222,"links":3311},[3312,3318,3319,3323],{"id":3059,"depth":222,"text":3060,"children":3313},[3314,3315,3316,3317],{"id":3063,"depth":708,"text":3064},{"id":3087,"depth":708,"text":3088},{"id":3166,"depth":708,"text":3167},{"id":3184,"depth":708,"text":3185},{"id":3202,"depth":222,"text":3203},{"id":3248,"depth":222,"text":3249,"children":3320},[3321,3322],{"id":3252,"depth":708,"text":3253},{"id":3273,"depth":708,"text":3274},{"id":2250,"depth":222,"text":2251},"Impressions of Zed Editor",{"date":3326},"2025-03-20","\u002Fen\u002F2025-03-25-zed",{"title":3034,"description":3324},"en\u002F2025-03-25-zed","Np1rx3ZnDUMW4jB9o8aMSLpKcdag088JUOt9oFrvrKs",{"id":3332,"title":3333,"body":3334,"description":3528,"extension":225,"meta":3529,"navigation":228,"path":3530,"seo":3531,"stem":3532,"__hash__":3533},"content\u002F2026-09-07-hotel-admin-part-2.md","Разработчик зачем-то админит гостиницу. Серия 2: меняем корневые свитчи и стараемся ничего не сломать",{"type":8,"value":3335,"toc":3526},[3336,3348,3351,3363,3384,3387,3390,3396,3399,3402,3405,3408,3414,3417,3445,3448,3451,3454,3465,3468,3471,3481,3487,3503,3506,3509,3512],[11,3337,3338,3339,3341,3342,3344,3345,3347],{},"В какой-то момент я решил, что прежде чем добавлять отдельный VLAN для гостевого Wi-Fi, неплохо бы сначала привести в порядок само ядро сети. На тот момент оно состояло из нескольких ",[15,3340,17],{}," разных поколений, старого ",[15,3343,21],{}," и ",[15,3346,25],{},". Часть оборудования выполняла понятную работу. Часть, судя по всему, просто исторически оказалась между двумя другими свитчами и продолжала жить там по принципу: \"работает — не трогай.\"",[11,3349,3350],{},"Из оборудования для замены у меня были:",[31,3352,3353,3358],{},[34,3354,3355,3356],{},"доставшийся по наследствую обесточенный ",[15,3357,39],{},[34,3359,3360,3361],{},"2 новых ",[15,3362,46],{},[11,3364,3365,3366,3368,3369,3371,3372,3374,3375,3377,3378,3380,3381,3383],{},"План был таков: ",[15,3367,53],{}," заменить на ",[15,3370,39],{}," из серверной, два корневых ",[15,3373,17],{}," заменить на новые ",[15,3376,46],{},", часть старой цепочки убрать совсем, а клиентские линии со старого ",[15,3379,66],{}," перенести на ",[15,3382,25],{},"(этот у меня уже был).",[11,3385,3386],{},"Казалось бы: сохранил конфиги -> переставил кабели -> готово. Разумеется, нет.",[11,3388,3389],{},"Для начала выяснилось, что сначала нужно понять, куда вообще ведут все эти кабели. Подписи на портах местами устарели, часть устройств не имела нормальных имён, а некоторые соединения существовали только в старых схемах и уже давно не соответствовали реальности. Поэтому перед заменой я собирал сеть обратно по кусочкам:",[11,3391,3392,3393,3395],{},"SNMP, LLDP, таблицы MAC-адресов, DHCP, ARP, состояние портов и немного физического хождения между стойками. Нейронки в этом деле мне здорово помогли, а то парсить SNMP показатели пытаясь смотреть в ключи вида ",[15,3394,82],{},"(OID) то еще удовольствие.",[11,3397,3398],{},"Постепенно появилась таблица:",[11,3400,3401],{},"старый порт -> реальное устройство -> VLAN -> новый порт.",[11,3403,3404],{},"Причём кабель в выключенном порту нельзя было автоматически считать ненужным. Но и переносить его вслепую тоже нельзя.",[11,3406,3407],{},"Отдельным развлечением оказались старые VLAN. На прежних свитчах сохранилось несколько VLAN, в которых уже не было ни одного активного устройства. Копировать их на новое оборудование только потому, что они когда-то существовали, я не стал. В новую конфигурацию перенес только реально используемые сети.",[11,3409,3410,3411,3413],{},"Сами ",[15,3412,46],{}," сначала настраивал отдельно от production-сети.\nHostname, LLDP, RSTP, приоритеты root bridge, access-порты, tagged и untagged VLAN, PVID, ingress filtering.",[11,3415,3416],{},"На словах всё это звучит вполне последовательно(ну, когда знаешь все эти аббревиатуры).",[11,3418,3419,3420,3422,3423,3425,3426,3429,3430,3432,3433,3435,3436,3438,3439,3441,3442,3444],{},"На практике даже подключение к консоли оказалось отдельным квестом: FTDI-адаптер, 115200 8N1, ",[15,3421,110],{}," на macOS — и некоторое количество вопросительных знаков вместо нормального приглашения командной строки. В конечном итоге я пользовался утилитой ",[15,3424,114],{}," на маке, чтобы имитировать ",[15,3427,3428],{},"dhcp"," сервер при подключении к свитчу и выдавать нужный мне айпишник этому свитчу по его ",[15,3431,122],{},"-адресу. Адрес я узнавал по ",[15,3434,126],{}," от свитча, во время его startup, отлавливая через ",[15,3437,130],{},". Хоть в доке ",[15,3440,134],{}," и указано, что с завода свитчи настроены на статический адрес - это было не так, они ждали ",[15,3443,138],{}," поэтому пришлось выкручиваться.",[11,3446,3447],{},"Очень обнадёживающее начало миграции.",[11,3449,3450],{},"Management-адреса новых свитчей я не прописывал статически. Для них подготовил DHCP reservations, чтобы после отключения старого устройства новый свитч мог занять его прежний адрес.",[11,3452,3453],{},"Сам перенос делался поэтапно.",[150,3455,3456,3459,3462],{},[34,3457,3458],{},"Сначала основной uplink.",[34,3460,3461],{},"Потом проверка management-доступа, LLDP и связности.",[34,3463,3464],{},"И только после этого — остальные линии.",[11,3466,3467],{},"Старые свитчи при этом не сбрасывались и не разбирались. Они оставались готовым физическим rollback: если что-то идёт не так, возвращаем подписанные кабели обратно. Стоит заметить, что как и с прошлой заметкой свитчи эти можно было заменять только ночью, т.к. они корневые и их отключение вырубает сеть всему отелю.",[11,3469,3470],{},"Что-то, конечно же, пошло не так.",[11,3472,3473,3474,3477,3478,3480],{},"После переноса одного из внешних каналов начал пропадать интернет. Оказалось, со стороны провайдера приходят ",[15,3475,3476],{},"BPDU"," - ",[15,3479,176],{}," пакеты необходимые сетевому оборудованию для понимания кто здесь главный. Новый Eltex честно обрабатывает их через RSTP и в какой-то момент блокирует порт, т.к. считает себя \"root\"-свитчем, то есть главным. А по правилам Spanning Tree Protocol главным может быть только один свитч(«There can be only one!»).",[11,3482,3483,3484,3486],{},"Я забыл, что на старом D-Link включал блокировку BPDU на порту от провайдера 🙈 Повторил эту магию на Eltex, и uplink не заставил себя долго ждать. На внутренних соединениях ",[15,3485,183],{}," остался работать как раньше, чтобы не допустить случайных сетевых колец.",[11,3488,3489,3490,3493,3494,3496,3497,3499,3500,3502],{},"Потом обнаружился ещё один сюрприз. Кабель, ведущий к одному из access switch'ей(",[15,3491,3492],{},"Unifi US8","), нормально работал через старый HPE, но вообще не хотел поднимать link при прямом подключении к новому Eltex или Zyxel. Можно было долго объяснять это особенностями совместимости оборудования. Но тот факт, что соединение через HPE поднималось только на 100 Мбит\u002Fс, довольно прозрачно намекал на физическую линию: обжим, патч-панель или неполный набор пар. Ни обжимки, ни кросс-ножа, ни ethernet-тестера у меня с собой не было, поэтому старый ",[15,3495,66],{}," пришлось временно оставить в сети. Практически всё остальное с него уже переехало, но сам свитч продолжил работать как довольно большой и энергоёмкий переходник между одним кабелем и UniFi. В итоге корневые D-Link были заменены на ",[15,3498,46],{},", клиентские линии переехали на ",[15,3501,25],{},", лишний транзитный свитч исчез, а между основными узлами осталась нормальная оптика.",[11,3504,3505],{},"Главным результатом для меня стали даже не новые свитчи. После этой миграции у сети впервые появилась схема, которой можно было более-менее доверять.",[11,3507,3508],{},"С реальными портами, VLAN, uplink’ами, исключениями и понятным rollback. После этого можно переходить к следующей непростой задаче: вынести гостевой Wi-Fi в отдельный VLAN. Не переключайтесь.",[11,3510,3511],{},"На фото:",[150,3513,3514,3520],{},[34,3515,3516,3517],{},"Текущая обезличенная карта сети. На ней уже нет старого HPE, т.к. с кабелем я разобрался. На нём были перепутаны пары. 🤷‍♂️\n",[210,3518],{"alt":3519,"src":213},"Обезличенная схема сети с портами, VLAN и моделями оборудования",[34,3521,3522,3523],{},"Тот свитч, что достался мне в наследство. Его я настраивал первым.\n",[210,3524],{"alt":3525,"src":219},"Коммутатор Eltex MES2428P",{"title":221,"searchDepth":222,"depth":222,"links":3527},[],"Замена корневых свитчей на Eltex, восстановление схемы сети, сюрпризы с BPDU и временный HPE в роли переходника.",{"date":227},"\u002F2026-09-07-hotel-admin-part-2",{"title":3333,"description":3528},"2026-09-07-hotel-admin-part-2","j_9lc2AJ7V-cUhixhelc8mCG9I_frujM4l4Fj3SzJuo",{"id":3535,"title":3536,"body":3537,"description":3608,"extension":225,"meta":3609,"navigation":228,"path":3610,"seo":3611,"stem":3612,"__hash__":3613},"content\u002F2026-09-04-hotel-admin-part-1.md","Разработчик зачем-то админит гостиницу. Серия 1: меняем процессор в сервере",{"type":8,"value":3538,"toc":3606},[3539,3542,3545,3548,3551,3568,3574,3577,3580,3583,3586,3589,3592,3595,3598,3601],[11,3540,3541],{},"Есть у меня гостиница, инфраструктуру которой я каким-то образом поддерживаю последние пол года(с апреля этого года).",[11,3543,3544],{},"Где-то в мае, после одного месяца погружения, стало понятно, что один из серверов на SuperMicro неплохо бы проапгрейдить.",[11,3546,3547],{},"Я вообще-то fullstack-разработчик.",[11,3549,3550],{},"Поэтому естественным образом через некоторое время сидел и выяснял:",[31,3552,3553,3556,3559,3562,3565],{},[34,3554,3555],{},"какой там сокет;",[34,3557,3558],{},"какие Xeon поддерживает материнка;",[34,3560,3561],{},"нужен ли новый BIOS;",[34,3563,3564],{},"вывезет ли охлаждение;",[34,3566,3567],{},"и какой процессор вообще имеет смысл покупать для этой древней железки.",[11,3569,3570,3571,3573],{},"Подобрал CPU. Моим удивлением было, что для этой платформы 2017-го(или 18-го?) года подошёл CPU всего за 4 тысячи рублей(?!). ",[271,3572,273],{},". При этом процессор увеличивал ресурс CPU чуть ли не в 5 раз. Из 4 ядер стало 20. Неплохой такой апгрейд, неправда ли? Попутно добрали и ОЗУ планки. Было 64 Гб, целились в ~190 гб. Заказали.",[11,3575,3576],{},"А дальше наступил особенно прекрасный момент: процессор и память в сервере ещё и кто-то должен физически поменять.",[11,3578,3579],{},"Этим кем-то, разумеется, тоже оказался я.",[11,3581,3582],{},"На сервере крутится Proxmox со всем нужным отелю ПО. Поэтому его нельзя выключать посреди рабочего дня(или даже выходного, отель всё таки😅)",[11,3584,3585],{},"В итоге: 3 часа ночи, сервер разобран, радиатор снят, старый Xeon вынут, новый установлен, термопаста нанесена, сервер собран обратно.",[11,3587,3588],{},"Нажимаю кнопку питания. Вытираю пот со лба, будто тот самый пилот с популярной гифки, который пытается посадить самолёт.",[11,3590,3591],{},"И вот эти несколько секунд до появления картинки на мониторе почему-то волнуют значительно сильнее любого docker stack deploy",[11,3593,3594],{},"Загрузился.",[11,3596,3597],{},"Так в мой стек незаметно добавился ещё и апгрейд серверных Xeon.",[11,3599,3600],{},"Прикладываю фото процесса снятия старого процессора(бадум-тсс). К сожалению платформа вся в строительной пыли, т.к. прямо над сервером бурили в стене дыру для каких-то коммуникаций. Я как мог это конечно запылесосил, но результата \"после\" не заснял, хотел побыстрее уже собрать и проверить работоспособность.",[11,3602,3603],{},[210,3604],{"alt":3605,"src":311},"Замена процессора в сервере",{"title":221,"searchDepth":222,"depth":222,"links":3607},[],"Ночной апгрейд сервера SuperMicro: Xeon Gold 6138, переход с 4 на 20 ядер и немного волнения перед первым запуском.",{"date":316},"\u002F2026-09-04-hotel-admin-part-1",{"title":3536,"description":3608},"2026-09-04-hotel-admin-part-1","5D9QO9GrJh9gdLfjAdZ8f_ZlT0aFun1cDMXmmm3aNGU",{"id":3615,"title":3616,"body":3617,"description":3717,"extension":225,"meta":3718,"navigation":228,"path":3719,"seo":3720,"stem":3721,"__hash__":3722},"content\u002F2026-04-13-remote-raspberries.md","Удалённое подключение к локальной сети без публичного шлюза",{"type":8,"value":3618,"toc":3711},[3619,3623,3626,3630,3633,3637,3640,3644,3702,3704,3709],[327,3620,3622],{"id":3621},"задача","Задача",[11,3624,3625],{},"Получить возможность подключаться к удалённой локальной сети.",[327,3627,3629],{"id":3628},"для-чего","Для чего",[11,3631,3632],{},"Например, чтобы управлять удалёнными устройствами: роутерами, точками доступа Wi-Fi и так далее.",[327,3634,3636],{"id":3635},"ограничения","Ограничения",[11,3638,3639],{},"Нет работающего хоста и публичного IP-адреса.",[327,3641,3643],{"id":3642},"как-решил-я","Как решил я",[150,3645,3646,3649,3668,3671,3693,3699],{},[34,3647,3648],{},"Покупаем Raspberry Pi 4.",[34,3650,3651,3652],{},"Подготавливаем устройство:",[31,3653,3654,3657,3660],{},[34,3655,3656],{},"устанавливаем Raspberry Pi OS Lite. Версия Lite важна, чтобы не нагружать устройство: GUI с X Server нам ни к чему;",[34,3658,3659],{},"убеждаемся, что подключили Raspberry Pi Connect. Тогда до устройства всегда можно достучаться, пока у него есть доступ в интернет;",[34,3661,3662,3663,3667],{},"устанавливаем ",[375,3664,3666],{"href":377,"rel":3665},[379],"Tailscale"," и подключаем его к своему аккаунту.",[34,3669,3670],{},"На месте подключаем устройство. Я решил использовать проводное соединение RJ-45: мне хотелось быть уверенным, что устройство не потеряет связь.",[34,3672,3673,3674,3686,3688,3689,3692],{},"Самая важная деталь:",[393,3675,3676],{"className":395,"code":396,"language":397,"meta":221,"style":221},[15,3677,3678],{"__ignoreMap":221},[401,3679,3680,3682,3684],{"class":403,"line":404},[401,3681,408],{"class":407},[401,3683,412],{"class":411},[401,3685,416],{"class":415},[452,3687],{},"Расшариваем подсеть клиентам сети Tailscale, чтобы потом, например, иметь возможность подключиться к роутеру — допустим, ",[15,3690,3691],{},"192.168.1.1"," — в той сети, где установлено устройство.",[34,3694,3695,3696,3698],{},"В админке Tailscale включаем ",[431,3697,433],{},", чтобы дать доступ к подсети.",[34,3700,3701],{},"Вуаля! Теперь можно подключаться к локальной сети, в которой размещён ваш «пирожок».",[444,3703],{},[11,3705,3706,3708],{},[431,3707,450],{}," Конечно, это не всегда работает на 100%: иногда машины нужно попинговать внутри сети Tailscale, чтобы они нашли друг друга. Но задачу всё равно решает.",[456,3710,458],{},{"title":221,"searchDepth":222,"depth":222,"links":3712},[3713,3714,3715,3716],{"id":3621,"depth":222,"text":3622},{"id":3628,"depth":222,"text":3629},{"id":3635,"depth":222,"text":3636},{"id":3642,"depth":222,"text":3643},"Raspberry Pi и Tailscale спешат на помощь!",{"date":467},"\u002F2026-04-13-remote-raspberries",{"title":3616,"description":3717},"2026-04-13-remote-raspberries","1tQnPRHYmLehzv1yjgzDf2uZosAjcfhr1lV12WV6_Zc",{"id":3724,"title":3725,"body":3726,"description":4069,"extension":225,"meta":4070,"navigation":228,"path":4071,"seo":4072,"stem":4073,"__hash__":4074},"content\u002F2025-10-12-laravel-cron-jobs.md","Запуск cron-задач Laravel в Docker с FrankenPHP",{"type":8,"value":3727,"toc":4064},[3728,3731,3751,3758,3764,3794,3797,3803,3827,3832,3836,3842,3849,3853,3858,3863,3939,3942,3947,3955,3958,3962,3970,4052,4055,4062],[11,3729,3730],{},"Вчера сидел и разбирался с запуском cron-задач в Laravel. Есть там две бесхитростные команды:",[393,3732,3733],{"className":395,"code":482,"language":397,"meta":221,"style":221},[15,3734,3735,3743],{"__ignoreMap":221},[401,3736,3737,3739,3741],{"class":403,"line":404},[401,3738,489],{"class":407},[401,3740,492],{"class":411},[401,3742,495],{"class":411},[401,3744,3745,3747,3749],{"class":403,"line":222},[401,3746,489],{"class":407},[401,3748,492],{"class":411},[401,3750,504],{"class":411},[11,3752,3753,3754,3757],{},"Подразумевается, что первую мы добавим в настоящий ",[15,3755,3756],{},"crontab"," в окружении, где крутится бэкенд, или в любом другом месте, где есть нужные переменные окружения и возможность подключиться к БД.",[11,3759,3760,3761,3763],{},"В ",[15,3762,3756],{}," должно быть что-то такое:",[393,3765,3766],{"className":395,"code":517,"language":397,"meta":221,"style":221},[15,3767,3768],{"__ignoreMap":221},[401,3769,3770,3772,3774,3776,3778,3780,3782,3784,3786,3788,3790,3792],{"class":403,"line":404},[401,3771,525],{"class":524},[401,3773,528],{"class":524},[401,3775,528],{"class":524},[401,3777,528],{"class":524},[401,3779,528],{"class":524},[401,3781,538],{"class":537},[401,3783,489],{"class":407},[401,3785,492],{"class":411},[401,3787,545],{"class":411},[401,3789,548],{"class":524},[401,3791,551],{"class":411},[401,3793,554],{"class":524},[11,3795,3796],{},"Это означает запуск каждую минуту.",[11,3798,3799,3800,3802],{},"Идея в том, чтобы сам график выполнения команд передать под управление PHP — и Laravel в частности. При запуске этой команды скрипт смотрит в ",[15,3801,570],{}," и проверяет зарегистрированные графики выполнения. Они выглядят примерно так:",[393,3804,3805],{"className":574,"code":575,"language":489,"meta":221,"style":221},[15,3806,3807],{"__ignoreMap":221},[401,3808,3809,3811,3813,3815,3817,3819,3821,3823,3825],{"class":403,"line":404},[401,3810,582],{"class":537},[401,3812,585],{"class":524},[401,3814,588],{"class":407},[401,3816,591],{"class":537},[401,3818,594],{"class":411},[401,3820,597],{"class":537},[401,3822,585],{"class":524},[401,3824,602],{"class":407},[401,3826,605],{"class":537},[11,3828,3829,3831],{},[15,3830,611],{}," в данном случае означает, что команда будет выполняться ежедневно.",[327,3833,3835],{"id":3834},"что-делает-schedulework","Что делает schedule:work?",[11,3837,3838,3839,3841],{},"Вторую команду, согласно документации, нужно запускать только локально. Всё, что она делает, — запускает ",[15,3840,640],{}," каждую минуту средствами PHP, используя бесконечный цикл.",[11,3843,3844,3848],{},[375,3845,3847],{"href":652,"rel":3846},[379],"Первый pull request с реализацией"," даёт исчерпывающее понимание того, как это работает. В современной версии Laravel код выглядит намного сложнее.",[327,3850,3852],{"id":3851},"мой-случай-docker-и-frankenphp","Мой случай: Docker и FrankenPHP",[11,3854,3855,3856,296],{},"Я запускаю бэкенд-приложение в Docker с помощью Docker Compose. Базовый образ — ",[15,3857,672],{},[11,3859,3860,3861,679],{},"Вот как сервис объявлен в ",[15,3862,678],{},[393,3864,3865],{"className":682,"code":683,"language":684,"meta":221,"style":221},[15,3866,3867,3873,3881,3887,3895,3901,3907,3915,3921,3929,3935],{"__ignoreMap":221},[401,3868,3869,3871],{"class":403,"line":404},[401,3870,692],{"class":691},[401,3872,695],{"class":537},[401,3874,3875,3877,3879],{"class":403,"line":222},[401,3876,700],{"class":691},[401,3878,173],{"class":537},[401,3880,705],{"class":411},[401,3882,3883,3885],{"class":403,"line":708},[401,3884,711],{"class":691},[401,3886,695],{"class":537},[401,3888,3889,3891,3893],{"class":403,"line":716},[401,3890,719],{"class":691},[401,3892,173],{"class":537},[401,3894,724],{"class":411},[401,3896,3897,3899],{"class":403,"line":727},[401,3898,730],{"class":691},[401,3900,695],{"class":537},[401,3902,3903,3905],{"class":403,"line":735},[401,3904,738],{"class":537},[401,3906,741],{"class":411},[401,3908,3909,3911,3913],{"class":403,"line":744},[401,3910,747],{"class":691},[401,3912,173],{"class":537},[401,3914,752],{"class":411},[401,3916,3917,3919],{"class":403,"line":755},[401,3918,758],{"class":691},[401,3920,695],{"class":537},[401,3922,3923,3925,3927],{"class":403,"line":763},[401,3924,766],{"class":691},[401,3926,173],{"class":537},[401,3928,771],{"class":415},[401,3930,3931,3933],{"class":403,"line":774},[401,3932,777],{"class":691},[401,3934,695],{"class":537},[401,3936,3937],{"class":403,"line":782},[401,3938,785],{"class":411},[11,3940,3941],{},"Каждую минуту я получал одну и ту же ошибку:",[393,3943,3945],{"className":3944,"code":791,"language":2053,"meta":221},[2051],[15,3946,791],{"__ignoreMap":221},[11,3948,3949,3950,3344,3952,3954],{},"По старой памяти я ожидал, что скрипт просто не может писать в файловую систему. Это частая проблема Laravel и его папок ",[15,3951,848],{},[15,3953,852],{},", когда приложение крутится в Docker.",[11,3956,3957],{},"Но не тут-то было.",[327,3959,3961],{"id":3960},"в-чём-оказалась-проблема","В чём оказалась проблема",[11,3963,3964,3965,3967,3968,679],{},"Как оказалось, FrankenPHP по какой-то причине не может создавать подпроцессы. Я немного поиграл с ",[15,3966,875],{}," в Docker-образе и плюнул — сделал простой shell-скрипт, который делает то же самое, что и ",[15,3969,624],{},[393,3971,3972],{"className":886,"code":887,"language":888,"meta":221,"style":221},[15,3973,3974,3978,3984,3988,3994,3998,4008,4012,4030,4034,4038,4048],{"__ignoreMap":221},[401,3975,3976],{"class":403,"line":404},[401,3977,896],{"class":895},[401,3979,3980,3982],{"class":403,"line":222},[401,3981,901],{"class":415},[401,3983,904],{"class":415},[401,3985,3986],{"class":403,"line":708},[401,3987,909],{"emptyLinePlaceholder":228},[401,3989,3990,3992],{"class":403,"line":716},[401,3991,914],{"class":415},[401,3993,917],{"class":411},[401,3995,3996],{"class":403,"line":727},[401,3997,909],{"emptyLinePlaceholder":228},[401,3999,4000,4002,4004,4006],{"class":403,"line":735},[401,4001,926],{"class":524},[401,4003,929],{"class":415},[401,4005,932],{"class":537},[401,4007,935],{"class":524},[401,4009,4010],{"class":403,"line":744},[401,4011,940],{"class":895},[401,4013,4014,4016,4018,4020,4022,4024,4026,4028],{"class":403,"line":755},[401,4015,945],{"class":407},[401,4017,948],{"class":537},[401,4019,951],{"class":407},[401,4021,954],{"class":411},[401,4023,957],{"class":537},[401,4025,960],{"class":407},[401,4027,963],{"class":411},[401,4029,966],{"class":537},[401,4031,4032],{"class":403,"line":763},[401,4033,909],{"emptyLinePlaceholder":228},[401,4035,4036],{"class":403,"line":774},[401,4037,975],{"class":895},[401,4039,4040,4042,4044,4046],{"class":403,"line":782},[401,4041,980],{"class":407},[401,4043,983],{"class":411},[401,4045,492],{"class":411},[401,4047,495],{"class":411},[401,4049,4050],{"class":403,"line":990},[401,4051,993],{"class":524},[11,4053,4054],{},"До этого момента я не испытывал никаких проблем с FrankenPHP. Он действительно сработал как drop-in replacement для образов, которые я использовал раньше.",[11,4056,4057,4061],{},[375,4058,4060],{"href":1012,"rel":4059},[379],"Worker mode"," пока не пробовал: ожидаю, что придётся основательно дорабатывать само приложение, чтобы всё это завелось.",[456,4063,1018],{},{"title":221,"searchDepth":222,"depth":222,"links":4065},[4066,4067,4068],{"id":3834,"depth":222,"text":3835},{"id":3851,"depth":222,"text":3852},{"id":3960,"depth":222,"text":3961},"Как запускать cron-задачи Laravel в Docker с FrankenPHP",{"date":1028},"\u002F2025-10-12-laravel-cron-jobs",{"title":3725,"description":4069},"2025-10-12-laravel-cron-jobs","abVI8lnukVkWZGkNeSrGR7qJZDky31EJnz9DzG_qy7g",{"id":4076,"title":4077,"body":4078,"description":4967,"extension":225,"meta":4968,"navigation":228,"path":4969,"seo":4970,"stem":4971,"__hash__":4972},"content\u002F2025-09-11-mysql-to-pg.md","Миграция большой базы данных из MySQL в PostgreSQL на AWS RDS",{"type":8,"value":4079,"toc":4955},[4080,4084,4114,4118,4124,4133,4137,4140,4143,4153,4156,4160,4163,4166,4282,4293,4297,4300,4309,4329,4332,4336,4339,4342,4398,4402,4405,4437,4440,4443,4473,4477,4480,4483,4486,4658,4662,4665,4668,4705,4717,4720,4725,4777,4780,4785,4788,4828,4848,4920,4925,4937,4941,4944,4952],[327,4081,4083],{"id":4082},"предусловия","Предусловия",[31,4085,4086,4092,4095,4102,4108],{},[34,4087,4088,4089,4091],{},"Новая база PostgreSQL должна крутиться в ",[431,4090,1052],{},", то есть доступа к файловой системе хоста БД нет.",[34,4093,4094],{},"Есть таблицы с миллионами записей — около 1,5 ГБ на одну таблицу.",[34,4096,4097,4098,4101],{},"Есть колонки типа ",[431,4099,4100],{},"JSON"," и созданные на основе этого JSON виртуальные колонки для возможности индексирования.",[34,4103,4104,4105,296],{},"Есть типы ",[431,4106,4107],{},"ENUM",[34,4109,4110,4111,4113],{},"Бэкенд написан на ",[431,4112,1083],{},", активно используются миграции БД.",[327,4115,4117],{"id":4116},"с-чего-начать","С чего начать?",[11,4119,4120,4121,4123],{},"Гуглим стандартные инструменты для переноса данных. Самый популярный — ",[431,4122,1100],{},". Я находил ещё что-то написанное на Node.js.",[11,4125,4126,4127,4129,4130,4132],{},"Оба варианта отмёл, потому что в итоге многие типы в таблицах оказываются не теми, что нужно: например, ",[15,4128,1109],{}," не превращается в ",[15,4131,1113],{},". Да и сама миграция занимает очень много времени, что является непозволительной роскошью при миграции продакшена.",[327,4134,4136],{"id":4135},"schema-first","Schema-first",[11,4138,4139],{},"Мои рассуждения привели меня к тому, что схему в PostgreSQL нужно создавать самому до миграции данных.",[11,4141,4142],{},"Это обусловлено проблемами, упомянутыми выше, а также тем, что нам требовалось изменить схему данных при миграции: например, добавить партиционирование и избавиться от устаревших таблиц.",[11,4144,4145,4146,4149,4150,4152],{},"Поэтому сначала я сгенерировал совместимую с Laravel миграцию на основе всей БД с помощью ",[375,4147,1164],{"href":1162,"rel":4148},[379],". Используем флаг ",[15,4151,1170],{},", чтобы сгенерировать одну огромную миграцию, и удаляем все прошлые миграции. Тогда при создании новой БД мы начнём с чистого листа и сразу создадим актуальную схему.",[11,4154,4155],{},"Естественно, мне пришлось дорабатывать сгенерированную миграцию.",[327,4157,4159],{"id":4158},"типы-enum","Типы ENUM",[11,4161,4162],{},"Enum'ы с Laravel в PostgreSQL становятся обычными ограничениями, а не полноценными типами БД. Кроме того, Grammar для написания миграций нужно расширить, чтобы при определении колонок таблиц можно было указывать пользовательские типы БД.",[11,4164,4165],{},"Выглядит это примерно так:",[393,4167,4169],{"className":574,"code":4168,"language":489,"meta":221,"style":221},"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    \u002F\u002F ...\n    $table->addColumn('country', 'country');\n});\n",[15,4170,4171,4189,4197,4201,4205,4229,4233,4255,4260,4278],{"__ignoreMap":221},[401,4172,4173,4175,4177,4179,4181,4183,4185,4187],{"class":403,"line":404},[401,4174,1208],{"class":415},[401,4176,1211],{"class":524},[401,4178,1214],{"class":407},[401,4180,591],{"class":537},[401,4182,1219],{"class":411},[401,4184,1222],{"class":537},[401,4186,1225],{"class":524},[401,4188,1228],{"class":537},[401,4190,4191,4193,4195],{"class":403,"line":222},[401,4192,1233],{"class":524},[401,4194,1236],{"class":411},[401,4196,1239],{"class":537},[401,4198,4199],{"class":403,"line":708},[401,4200,1244],{"class":537},[401,4202,4203],{"class":403,"line":716},[401,4204,909],{"emptyLinePlaceholder":228},[401,4206,4207,4209,4211,4213,4215,4217,4219,4221,4223,4225,4227],{"class":403,"line":727},[401,4208,1253],{"class":415},[401,4210,1211],{"class":524},[401,4212,1258],{"class":407},[401,4214,591],{"class":537},[401,4216,1263],{"class":411},[401,4218,1266],{"class":524},[401,4220,1269],{"class":524},[401,4222,1272],{"class":411},[401,4224,1275],{"class":524},[401,4226,1278],{"class":411},[401,4228,1281],{"class":537},[401,4230,4231],{"class":403,"line":735},[401,4232,909],{"emptyLinePlaceholder":228},[401,4234,4235,4237,4239,4241,4243,4245,4247,4249,4251,4253],{"class":403,"line":744},[401,4236,1290],{"class":415},[401,4238,1211],{"class":524},[401,4240,1295],{"class":407},[401,4242,591],{"class":537},[401,4244,1300],{"class":411},[401,4246,1222],{"class":537},[401,4248,1225],{"class":524},[401,4250,1307],{"class":537},[401,4252,1310],{"class":415},[401,4254,1313],{"class":537},[401,4256,4257],{"class":403,"line":755},[401,4258,4259],{"class":895},"    \u002F\u002F ...\n",[401,4261,4262,4264,4266,4268,4270,4272,4274,4276],{"class":403,"line":763},[401,4263,1323],{"class":537},[401,4265,585],{"class":524},[401,4267,1328],{"class":407},[401,4269,591],{"class":537},[401,4271,1333],{"class":411},[401,4273,1222],{"class":537},[401,4275,1333],{"class":411},[401,4277,1281],{"class":537},[401,4279,4280],{"class":403,"line":774},[401,4281,1244],{"class":537},[11,4283,4284,4285,4287,4288,4290,4291,296],{},"Если не использовать ",[15,4286,1349],{},", метод ",[15,4289,1328],{}," ничего не будет знать о типе с названием ",[15,4292,1353],{},[327,4294,4296],{"id":4295},"виртуальные-колонки-на-основе-json","Виртуальные колонки на основе JSON",[11,4298,4299],{},"Кроме enum'ов, нужно было что-то сделать с виртуальными колонками, созданными на основе JSON. В PostgreSQL нет виртуальных колонок как таковых.",[11,4301,4302,4303,4305,4306,4308],{},"Тут мне помог ChatGPT, предложив вместо виртуальной колонки использовать обычный индекс. Представим, что ",[15,4304,1377],{}," — это JSON-колонка таблицы ",[15,4307,1381],{},". Тогда индекс выглядит так:",[393,4310,4311],{"className":1385,"code":1386,"language":1387,"meta":221,"style":221},[15,4312,4313],{"__ignoreMap":221},[401,4314,4315,4317,4319,4321,4323,4325,4327],{"class":403,"line":404},[401,4316,1266],{"class":524},[401,4318,1396],{"class":524},[401,4320,1399],{"class":407},[401,4322,1402],{"class":537},[401,4324,1405],{"class":524},[401,4326,1408],{"class":411},[401,4328,1411],{"class":537},[11,4330,4331],{},"Главное — не забыть в коде приложения заменить обращение к виртуальной колонке, если оно было, на доступ по ключу в JSON-объекте.",[327,4333,4335],{"id":4334},"подготовка-к-партиционированию","Подготовка к партиционированию",[11,4337,4338],{},"Следующей задачей было подготовить одну из таблиц к партиционированию. В PostgreSQL таблица должна быть создана сразу с указанием того, что её собираются партиционировать.",[11,4340,4341],{},"В моём случае это выглядело так:",[393,4343,4345],{"className":1385,"code":4344,"language":1387,"meta":221,"style":221},"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",[15,4346,4347,4357,4365,4370,4380,4386],{"__ignoreMap":221},[401,4348,4349,4351,4353,4355],{"class":403,"line":404},[401,4350,1266],{"class":524},[401,4352,1444],{"class":524},[401,4354,1447],{"class":407},[401,4356,1450],{"class":537},[401,4358,4359,4361,4363],{"class":403,"line":222},[401,4360,1455],{"class":537},[401,4362,1458],{"class":524},[401,4364,1461],{"class":537},[401,4366,4367],{"class":403,"line":708},[401,4368,4369],{"class":895},"    -- ... другие колонки\n",[401,4371,4372,4374,4376,4378],{"class":403,"line":716},[401,4373,1470],{"class":537},[401,4375,1473],{"class":524},[401,4377,1476],{"class":524},[401,4379,1461],{"class":537},[401,4381,4382,4384],{"class":403,"line":727},[401,4383,1483],{"class":524},[401,4385,1486],{"class":537},[401,4387,4388,4390,4392,4394,4396],{"class":403,"line":735},[401,4389,1491],{"class":537},[401,4391,1494],{"class":524},[401,4393,1497],{"class":524},[401,4395,1500],{"class":524},[401,4397,1503],{"class":537},[327,4399,4401],{"id":4400},"необходимые-расширения","Необходимые расширения",[11,4403,4404],{},"Основной мотивацией переезда на PostgreSQL было использование векторных типов данных, поэтому тут же в миграции я добавил:",[393,4406,4407],{"className":574,"code":1521,"language":489,"meta":221,"style":221},[15,4408,4409],{"__ignoreMap":221},[401,4410,4411,4413,4415,4417,4419,4421,4423,4425,4427,4429,4431,4433,4435],{"class":403,"line":404},[401,4412,1253],{"class":415},[401,4414,1211],{"class":524},[401,4416,1258],{"class":407},[401,4418,591],{"class":537},[401,4420,1263],{"class":411},[401,4422,1266],{"class":524},[401,4424,1540],{"class":411},[401,4426,1543],{"class":524},[401,4428,1546],{"class":524},[401,4430,1549],{"class":524},[401,4432,1552],{"class":524},[401,4434,1555],{"class":411},[401,4436,1281],{"class":537},[11,4438,4439],{},"Этот запрос нужно выполнять на каждой вновь созданной базе PostgreSQL для каждого расширения, которое мы собираемся использовать в рамках этой БД.",[11,4441,4442],{},"Также мы использовали геопространственные функции для определения расстояний, поэтому не обошлось без:",[393,4444,4445],{"className":574,"code":1578,"language":489,"meta":221,"style":221},[15,4446,4447],{"__ignoreMap":221},[401,4448,4449,4451,4453,4455,4457,4459,4461,4463,4465,4467,4469,4471],{"class":403,"line":404},[401,4450,1253],{"class":415},[401,4452,1211],{"class":524},[401,4454,1258],{"class":407},[401,4456,591],{"class":537},[401,4458,1263],{"class":411},[401,4460,1266],{"class":524},[401,4462,1540],{"class":411},[401,4464,1543],{"class":524},[401,4466,1546],{"class":524},[401,4468,1549],{"class":524},[401,4470,1605],{"class":411},[401,4472,1281],{"class":537},[327,4474,4476],{"id":4475},"локальное-окружение","Локальное окружение",[11,4478,4479],{},"Упомянув активацию расширений в БД, нельзя не сказать, что сначала эти расширения должны быть установлены.",[11,4481,4482],{},"В AWS RDS они установлены по умолчанию, а для локальной разработки мы, как правило, используем готовые Docker-образы. Найти собранный под любую архитектуру образ PostgreSQL с pgvector или PostgreSQL с PostGIS по отдельности достаточно просто. А вот комбинации, где всё вместе работает на ARM64, нет.",[11,4484,4485],{},"Пришлось собирать самому:",[393,4487,4488],{"className":1645,"code":1646,"language":1647,"meta":221,"style":221},[15,4489,4490,4496,4500,4506,4510,4516,4520,4524,4528,4532,4536,4540,4544,4548,4552,4556,4560,4564,4568,4572,4576,4580,4584,4588,4592,4596,4602,4606,4610,4614,4618,4622,4626,4630,4634,4638,4642,4646,4650,4654],{"__ignoreMap":221},[401,4491,4492,4494],{"class":403,"line":404},[401,4493,1654],{"class":524},[401,4495,1657],{"class":537},[401,4497,4498],{"class":403,"line":222},[401,4499,909],{"emptyLinePlaceholder":228},[401,4501,4502,4504],{"class":403,"line":708},[401,4503,1666],{"class":524},[401,4505,1669],{"class":537},[401,4507,4508],{"class":403,"line":716},[401,4509,909],{"emptyLinePlaceholder":228},[401,4511,4512,4514],{"class":403,"line":727},[401,4513,1678],{"class":524},[401,4515,1681],{"class":537},[401,4517,4518],{"class":403,"line":735},[401,4519,1686],{"class":537},[401,4521,4522],{"class":403,"line":744},[401,4523,1691],{"class":537},[401,4525,4526],{"class":403,"line":755},[401,4527,1696],{"class":537},[401,4529,4530],{"class":403,"line":763},[401,4531,1701],{"class":537},[401,4533,4534],{"class":403,"line":774},[401,4535,1706],{"class":537},[401,4537,4538],{"class":403,"line":782},[401,4539,1711],{"class":537},[401,4541,4542],{"class":403,"line":990},[401,4543,1716],{"class":537},[401,4545,4546],{"class":403,"line":1719},[401,4547,1722],{"class":537},[401,4549,4550],{"class":403,"line":1725},[401,4551,1728],{"class":537},[401,4553,4554],{"class":403,"line":1731},[401,4555,1734],{"class":537},[401,4557,4558],{"class":403,"line":1737},[401,4559,1740],{"class":537},[401,4561,4562],{"class":403,"line":1743},[401,4563,1746],{"class":537},[401,4565,4566],{"class":403,"line":1749},[401,4567,1752],{"class":537},[401,4569,4570],{"class":403,"line":1755},[401,4571,1758],{"class":537},[401,4573,4574],{"class":403,"line":1761},[401,4575,1764],{"class":537},[401,4577,4578],{"class":403,"line":1767},[401,4579,1770],{"class":537},[401,4581,4582],{"class":403,"line":1773},[401,4583,1776],{"class":537},[401,4585,4586],{"class":403,"line":1779},[401,4587,1782],{"class":537},[401,4589,4590],{"class":403,"line":1785},[401,4591,1788],{"class":537},[401,4593,4594],{"class":403,"line":1791},[401,4595,909],{"emptyLinePlaceholder":228},[401,4597,4598,4600],{"class":403,"line":1796},[401,4599,1678],{"class":524},[401,4601,1801],{"class":537},[401,4603,4604],{"class":403,"line":1804},[401,4605,1807],{"class":537},[401,4607,4608],{"class":403,"line":1810},[401,4609,1813],{"class":537},[401,4611,4612],{"class":403,"line":1816},[401,4613,1819],{"class":537},[401,4615,4616],{"class":403,"line":1822},[401,4617,1825],{"class":537},[401,4619,4620],{"class":403,"line":1828},[401,4621,1831],{"class":537},[401,4623,4624],{"class":403,"line":1834},[401,4625,1837],{"class":537},[401,4627,4628],{"class":403,"line":1840},[401,4629,1843],{"class":537},[401,4631,4632],{"class":403,"line":1846},[401,4633,1849],{"class":537},[401,4635,4636],{"class":403,"line":1852},[401,4637,1855],{"class":537},[401,4639,4640],{"class":403,"line":1858},[401,4641,1861],{"class":537},[401,4643,4644],{"class":403,"line":1864},[401,4645,1867],{"class":537},[401,4647,4648],{"class":403,"line":1870},[401,4649,1873],{"class":537},[401,4651,4652],{"class":403,"line":1876},[401,4653,1879],{"class":537},[401,4655,4656],{"class":403,"line":1882},[401,4657,1885],{"class":537},[327,4659,4661],{"id":4660},"процесс-переноса","Процесс переноса",[11,4663,4664],{},"Предварительно обкатав процесс в локальном окружении, я приступил к переносу на стендовых инстансах — stage и production.",[11,4666,4667],{},"Процесс выглядел так:",[150,4669,4670,4673,4678,4686,4694,4699,4702],{},[34,4671,4672],{},"Создаём инстанс RDS в облаке.",[34,4674,4675,4676,296],{},"Подключаемся клиентом ",[15,4677,1915],{},[34,4679,4680,4681,3344,4683,4685],{},"Включаем расширения ",[15,4682,1925],{},[15,4684,1930],{},". Они нужны, чтобы использовать файлы из S3 для импорта CSV. Напомню: в RDS у нас нет доступа к файловой системе хоста БД.",[34,4687,4688,4689,4693],{},"Создаём ",[375,4690,1945],{"href":4691,"rel":4692},"https:\u002F\u002Fdocs.aws.amazon.com\u002Fvpc\u002Flatest\u002Fprivatelink\u002Fvpc-endpoints-s3.html",[379]," для доступа базы данных к S3.",[34,4695,4696,4697,296],{},"Изменяем конфигурацию подключения к БД в Laravel — ",[15,4698,1952],{},[34,4700,4701],{},"Деплоим код с новой миграцией и применяем созданную нами схему к базе PostgreSQL.",[34,4703,4704],{},"Останавливаем приложение, чтобы исключить новые записи в БД:",[393,4706,4707],{"className":395,"code":1961,"language":397,"meta":221,"style":221},[15,4708,4709],{"__ignoreMap":221},[401,4710,4711,4713,4715],{"class":403,"line":404},[401,4712,489],{"class":407},[401,4714,492],{"class":411},[401,4716,1972],{"class":411},[11,4718,4719],{},"Эта команда включает maintenance mode.",[150,4721,4722],{"start":755},[34,4723,4724],{},"Делаем дамп CSV-файлов:",[393,4726,4727],{"className":395,"code":1980,"language":397,"meta":221,"style":221},[15,4728,4729,4745,4751,4757,4763,4771],{"__ignoreMap":221},[401,4730,4731,4733,4735,4737,4739,4741,4743],{"class":403,"line":404},[401,4732,1987],{"class":407},[401,4734,1990],{"class":415},[401,4736,1993],{"class":415},[401,4738,1996],{"class":411},[401,4740,1999],{"class":415},[401,4742,2002],{"class":411},[401,4744,2005],{"class":415},[401,4746,4747,4749],{"class":403,"line":222},[401,4748,2010],{"class":415},[401,4750,2005],{"class":415},[401,4752,4753,4755],{"class":403,"line":708},[401,4754,2017],{"class":415},[401,4756,2005],{"class":415},[401,4758,4759,4761],{"class":403,"line":716},[401,4760,2024],{"class":415},[401,4762,2005],{"class":415},[401,4764,4765,4767,4769],{"class":403,"line":727},[401,4766,2031],{"class":415},[401,4768,2034],{"class":411},[401,4770,2005],{"class":415},[401,4772,4773,4775],{"class":403,"line":735},[401,4774,2041],{"class":415},[401,4776,2044],{"class":411},[11,4778,4779],{},"Это создаст отдельный файл для каждой существующей таблицы в БД. Например:",[393,4781,4783],{"className":4782,"code":2052,"language":2053,"meta":221},[2051],[15,4784,2052],{"__ignoreMap":221},[11,4786,4787],{},"Параметры команды:",[31,4789,4790,4795,4800,4809,4823],{},[34,4791,4792,4794],{},[15,4793,2065],{}," — отключаем генерацию схемы;",[34,4796,4797,4799],{},[15,4798,2071],{}," — разделитель-запятая;",[34,4801,4802,4804,4805,4808],{},[15,4803,2077],{}," — сохраняем файлы в ",[15,4806,4807],{},"\u002Fvar\u002Flib\u002Fmysql-files",". По умолчанию MySQL может экспортировать только в эту папку. Если её нет, нужно заранее создать вручную;",[34,4810,4811,4814,4815,4818,4819,4822],{},[15,4812,4813],{},"--fields-enclosed-by='\\\"'"," — оборачиваем каждое значение в ",[15,4816,4817],{},"\\\"",". Именно такой формат ожидает ",[15,4820,4821],{},"COPY"," в PostgreSQL;",[34,4824,4825,4827],{},[15,4826,2089],{}," — устанавливаем символ перевода строки.",[150,4829,4830,4842],{"start":763},[34,4831,4832,4833,4835,4836,296],{},"Переносим ",[15,4834,2098],{},"-файлы в S3-бакет. Если вы, как и я, генерировали файлы внутри контейнера, вам поможет ",[375,4837,4840],{"href":4838,"rel":4839},"https:\u002F\u002Fdocs.docker.com\u002Freference\u002Fcli\u002Fdocker\u002Fcontainer\u002Fcp\u002F",[379],[15,4841,2106],{},[34,4843,4844,4845,4847],{},"Затем импортируем каждый файл в ",[15,4846,1915],{}," следующей командой — по одной на каждую таблицу:",[393,4849,4850],{"className":1385,"code":2113,"language":1387,"meta":221,"style":221},[15,4851,4852,4864,4870,4876,4882,4888,4894,4900,4906,4912,4916],{"__ignoreMap":221},[401,4853,4854,4856,4858,4860,4862],{"class":403,"line":404},[401,4855,2120],{"class":524},[401,4857,2123],{"class":415},[401,4859,296],{"class":537},[401,4861,2128],{"class":415},[401,4863,2131],{"class":537},[401,4865,4866,4868],{"class":403,"line":222},[401,4867,2136],{"class":411},[401,4869,1461],{"class":537},[401,4871,4872,4874],{"class":403,"line":708},[401,4873,2143],{"class":411},[401,4875,1461],{"class":537},[401,4877,4878,4880],{"class":403,"line":716},[401,4879,2150],{"class":411},[401,4881,1461],{"class":537},[401,4883,4884,4886],{"class":403,"line":727},[401,4885,2157],{"class":411},[401,4887,1461],{"class":537},[401,4889,4890,4892],{"class":403,"line":735},[401,4891,2164],{"class":411},[401,4893,1461],{"class":537},[401,4895,4896,4898],{"class":403,"line":744},[401,4897,2171],{"class":411},[401,4899,1461],{"class":537},[401,4901,4902,4904],{"class":403,"line":755},[401,4903,2178],{"class":411},[401,4905,1461],{"class":537},[401,4907,4908,4910],{"class":403,"line":763},[401,4909,2185],{"class":411},[401,4911,1461],{"class":537},[401,4913,4914],{"class":403,"line":774},[401,4915,2192],{"class":411},[401,4917,4918],{"class":403,"line":782},[401,4919,1281],{"class":537},[150,4921,4922],{"start":782},[34,4923,4924],{},"Запускаем приложение:",[393,4926,4927],{"className":395,"code":2207,"language":397,"meta":221,"style":221},[15,4928,4929],{"__ignoreMap":221},[401,4930,4931,4933,4935],{"class":403,"line":404},[401,4932,489],{"class":407},[401,4934,492],{"class":411},[401,4936,2218],{"class":411},[327,4938,4940],{"id":4939},"что-ещё-нужно-учесть","Что ещё нужно учесть?",[11,4942,4943],{},"При создании схемы для PostgreSQL можно выбрать два пути:",[150,4945,4946,4949],{},[34,4947,4948],{},"Создать схему без внешних ключей, чтобы при импорте был неважен порядок загрузки таблиц. В таком случае понадобится ещё одна миграция для добавления внешних ключей.",[34,4950,4951],{},"Создать схему сразу с ключами. Тогда порядок импорта таблиц из файлов будет важен.",[456,4953,4954],{},"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}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}",{"title":221,"searchDepth":222,"depth":222,"links":4956},[4957,4958,4959,4960,4961,4962,4963,4964,4965,4966],{"id":4082,"depth":222,"text":4083},{"id":4116,"depth":222,"text":4117},{"id":4135,"depth":222,"text":4136},{"id":4158,"depth":222,"text":4159},{"id":4295,"depth":222,"text":4296},{"id":4334,"depth":222,"text":4335},{"id":4400,"depth":222,"text":4401},{"id":4475,"depth":222,"text":4476},{"id":4660,"depth":222,"text":4661},{"id":4939,"depth":222,"text":4940},"Опыт миграции большой базы данных из MySQL в PostgreSQL на AWS RDS",{"date":2300},"\u002F2025-09-11-mysql-to-pg",{"title":4077,"description":4967},"2025-09-11-mysql-to-pg","RVUMD1af042J-ZfOsV9Cbutm_9ME91SJUaRh2F0M4d0",{"id":4974,"title":4975,"body":4976,"description":5440,"extension":225,"meta":5441,"navigation":228,"path":5442,"seo":5443,"stem":5444,"__hash__":5445},"content\u002F2025-04-25-env-files.md",".env-файлы",{"type":8,"value":4977,"toc":5434},[4978,4981,4985,4992,4997,5000,5006,5009,5015,5017,5024,5030,5048,5051,5055,5061,5065,5072,5432],[11,4979,4980],{},"Безопасное хранение секретов, а также правильное использование этой «чувствительной» информации в переменных окружения давно волнует меня.",[327,4982,4984],{"id":4983},"как-было-раньше-локальное-шифрование","Как было раньше: локальное шифрование",[11,4986,4987,4988,4991],{},"Раньше я использовал локальное шифрование. Применял обычный ",[431,4989,4990],{},"OpenSSL",", установленный практически в любой системе, передавал ему keyfile (обычно он хранился в каком-нибудь укромном Google Документе) и получал зашифрованный файл с соответствующим окружению суффиксом, например:",[393,4993,4995],{"className":4994,"code":2363,"language":2053,"meta":221},[2051],[15,4996,2363],{"__ignoreMap":221},[11,4998,4999],{},"Чтобы обновить или добавить переменную, приходилось заново проводить процедуру шифрования и дешифровки для каждого окружения, что само по себе довольно утомительно.",[11,5001,5002,5003,5005],{},"Поэтому часто я пользовался одним общим ",[15,5004,2352],{},"-файлом для всех приложений, лишь бы облегчить себе жизнь — хотя кого я обманываю?",[11,5007,5008],{},"Таким образом, переменные окружения для бэкенда оказывались среди переменных фронтенда. Например, данные подключения к базе данных 🤪",[11,5010,5011,5012,5014],{},"Ещё одной проблемой была необходимость хранить зашифрованные ",[15,5013,2352],{},"-файлы в отдельном месте, чаще всего в специальном репозитории. Их обновление происходило отдельно от обновления самого приложения, создавая дополнительное пространство для ошибок.",[327,5016,2471],{"id":2495},[11,5018,5019,5020,5023],{},"Совсем недавно я обнаружил ",[375,5021,2471],{"href":2469,"rel":5022},[379],". Теперь управлять секретами стало проще благодаря удобному веб-интерфейсу.",[11,5025,5026,5027,5029],{},"Никакие ",[15,5028,2352],{},"-файлы больше не нужны — достаточно команды:",[393,5031,5032],{"className":395,"code":2488,"language":397,"meta":221,"style":221},[15,5033,5034],{"__ignoreMap":221},[401,5035,5036,5038,5040,5042,5044,5046],{"class":403,"line":404},[401,5037,2495],{"class":407},[401,5039,2498],{"class":411},[401,5041,2501],{"class":415},[401,5043,2504],{"class":411},[401,5045,2498],{"class":411},[401,5047,2509],{"class":411},[11,5049,5050],{},"И приложение собирается с заранее настроенным конфигурационным профилем. Доступны интеграции для большинства современных инструментов развёртывания приложений.",[327,5052,5054],{"id":5053},"почему-я-всё-же-оставил-env-файлы","Почему я всё же оставил .env-файлы",[11,5056,5057,5058,5060],{},"Я не люблю сильно зависеть от инструментов, поэтому не стал совсем отказываться от ",[15,5059,2352],{},"-файлов. Пока решил просто автоматически генерировать их с помощью Doppler.",[327,5062,5064],{"id":5063},"автоматизация-с-помощью-taskfile","Автоматизация с помощью Taskfile",[11,5066,5067,5068,5071],{},"Я создал небольшой ",[375,5069,2565],{"href":2563,"rel":5070},[379]," для первоначального запуска проекта с правильными переменными окружения:",[393,5073,5074],{"className":682,"code":2569,"language":684,"meta":221,"style":221},[15,5075,5076,5080,5088,5092,5098,5104,5110,5116,5120,5126,5132,5138,5142,5148,5154,5162,5170,5178,5184,5190,5194,5200,5206,5216,5246,5250,5256,5262,5272,5302,5306,5312,5318,5328,5358,5362,5368,5386,5390,5396,5402,5412,5422],{"__ignoreMap":221},[401,5077,5078],{"class":403,"line":404},[401,5079,2576],{"class":895},[401,5081,5082,5084,5086],{"class":403,"line":222},[401,5083,2581],{"class":691},[401,5085,173],{"class":537},[401,5087,2586],{"class":411},[401,5089,5090],{"class":403,"line":708},[401,5091,909],{"emptyLinePlaceholder":228},[401,5093,5094,5096],{"class":403,"line":716},[401,5095,2595],{"class":691},[401,5097,695],{"class":537},[401,5099,5100,5102],{"class":403,"line":727},[401,5101,2602],{"class":691},[401,5103,695],{"class":537},[401,5105,5106,5108],{"class":403,"line":735},[401,5107,2609],{"class":691},[401,5109,695],{"class":537},[401,5111,5112,5114],{"class":403,"line":744},[401,5113,2616],{"class":537},[401,5115,2619],{"class":411},[401,5117,5118],{"class":403,"line":755},[401,5119,909],{"emptyLinePlaceholder":228},[401,5121,5122,5124],{"class":403,"line":763},[401,5123,2628],{"class":691},[401,5125,695],{"class":537},[401,5127,5128,5130],{"class":403,"line":774},[401,5129,2609],{"class":691},[401,5131,695],{"class":537},[401,5133,5134,5136],{"class":403,"line":782},[401,5135,2616],{"class":537},[401,5137,2643],{"class":411},[401,5139,5140],{"class":403,"line":990},[401,5141,2648],{"class":537},[401,5143,5144,5146],{"class":403,"line":1719},[401,5145,2653],{"class":691},[401,5147,695],{"class":537},[401,5149,5150,5152],{"class":403,"line":1725},[401,5151,2660],{"class":691},[401,5153,695],{"class":537},[401,5155,5156,5158,5160],{"class":403,"line":1731},[401,5157,2667],{"class":691},[401,5159,173],{"class":537},[401,5161,2672],{"class":411},[401,5163,5164,5166,5168],{"class":403,"line":1737},[401,5165,2677],{"class":691},[401,5167,173],{"class":537},[401,5169,2682],{"class":411},[401,5171,5172,5174,5176],{"class":403,"line":1743},[401,5173,2687],{"class":691},[401,5175,173],{"class":537},[401,5177,2692],{"class":411},[401,5179,5180,5182],{"class":403,"line":1749},[401,5181,2609],{"class":691},[401,5183,695],{"class":537},[401,5185,5186,5188],{"class":403,"line":1755},[401,5187,2616],{"class":537},[401,5189,2705],{"class":411},[401,5191,5192],{"class":403,"line":1761},[401,5193,2648],{"class":537},[401,5195,5196,5198],{"class":403,"line":1767},[401,5197,2714],{"class":691},[401,5199,695],{"class":537},[401,5201,5202,5204],{"class":403,"line":1773},[401,5203,2609],{"class":691},[401,5205,695],{"class":537},[401,5207,5208,5210,5212,5214],{"class":403,"line":1779},[401,5209,2616],{"class":537},[401,5211,329],{"class":691},[401,5213,173],{"class":537},[401,5215,2733],{"class":411},[401,5217,5218,5220,5222,5224,5226,5228,5230,5232,5234,5236,5238,5240,5242,5244],{"class":403,"line":1785},[401,5219,2738],{"class":691},[401,5221,2741],{"class":537},[401,5223,2744],{"class":691},[401,5225,173],{"class":537},[401,5227,2749],{"class":411},[401,5229,1222],{"class":537},[401,5231,2754],{"class":691},[401,5233,173],{"class":537},[401,5235,2759],{"class":411},[401,5237,1222],{"class":537},[401,5239,2764],{"class":691},[401,5241,173],{"class":537},[401,5243,2769],{"class":411},[401,5245,2772],{"class":537},[401,5247,5248],{"class":403,"line":1791},[401,5249,909],{"emptyLinePlaceholder":228},[401,5251,5252,5254],{"class":403,"line":1796},[401,5253,2781],{"class":691},[401,5255,695],{"class":537},[401,5257,5258,5260],{"class":403,"line":1804},[401,5259,2609],{"class":691},[401,5261,695],{"class":537},[401,5263,5264,5266,5268,5270],{"class":403,"line":1810},[401,5265,2616],{"class":537},[401,5267,329],{"class":691},[401,5269,173],{"class":537},[401,5271,2733],{"class":411},[401,5273,5274,5276,5278,5280,5282,5284,5286,5288,5290,5292,5294,5296,5298,5300],{"class":403,"line":1816},[401,5275,2738],{"class":691},[401,5277,2741],{"class":537},[401,5279,2744],{"class":691},[401,5281,173],{"class":537},[401,5283,2812],{"class":411},[401,5285,1222],{"class":537},[401,5287,2754],{"class":691},[401,5289,173],{"class":537},[401,5291,2759],{"class":411},[401,5293,1222],{"class":537},[401,5295,2764],{"class":691},[401,5297,173],{"class":537},[401,5299,2829],{"class":411},[401,5301,2772],{"class":537},[401,5303,5304],{"class":403,"line":1822},[401,5305,2648],{"class":537},[401,5307,5308,5310],{"class":403,"line":1828},[401,5309,2840],{"class":691},[401,5311,695],{"class":537},[401,5313,5314,5316],{"class":403,"line":1834},[401,5315,2609],{"class":691},[401,5317,695],{"class":537},[401,5319,5320,5322,5324,5326],{"class":403,"line":1840},[401,5321,2616],{"class":537},[401,5323,329],{"class":691},[401,5325,173],{"class":537},[401,5327,2733],{"class":411},[401,5329,5330,5332,5334,5336,5338,5340,5342,5344,5346,5348,5350,5352,5354,5356],{"class":403,"line":1846},[401,5331,2738],{"class":691},[401,5333,2741],{"class":537},[401,5335,2744],{"class":691},[401,5337,173],{"class":537},[401,5339,2871],{"class":411},[401,5341,1222],{"class":537},[401,5343,2754],{"class":691},[401,5345,173],{"class":537},[401,5347,2759],{"class":411},[401,5349,1222],{"class":537},[401,5351,2764],{"class":691},[401,5353,173],{"class":537},[401,5355,2888],{"class":411},[401,5357,2772],{"class":537},[401,5359,5360],{"class":403,"line":1852},[401,5361,909],{"emptyLinePlaceholder":228},[401,5363,5364,5366],{"class":403,"line":1858},[401,5365,2899],{"class":691},[401,5367,695],{"class":537},[401,5369,5370,5372,5374,5376,5378,5380,5382,5384],{"class":403,"line":1864},[401,5371,2906],{"class":691},[401,5373,2909],{"class":537},[401,5375,2912],{"class":411},[401,5377,1222],{"class":537},[401,5379,2917],{"class":411},[401,5381,1222],{"class":537},[401,5383,2922],{"class":411},[401,5385,2925],{"class":537},[401,5387,5388],{"class":403,"line":1870},[401,5389,909],{"emptyLinePlaceholder":228},[401,5391,5392,5394],{"class":403,"line":1876},[401,5393,2934],{"class":691},[401,5395,695],{"class":537},[401,5397,5398,5400],{"class":403,"line":1882},[401,5399,2609],{"class":691},[401,5401,695],{"class":537},[401,5403,5404,5406,5408,5410],{"class":403,"line":2945},[401,5405,2616],{"class":537},[401,5407,329],{"class":691},[401,5409,173],{"class":537},[401,5411,2954],{"class":411},[401,5413,5414,5416,5418,5420],{"class":403,"line":2957},[401,5415,2616],{"class":537},[401,5417,329],{"class":691},[401,5419,173],{"class":537},[401,5421,2966],{"class":411},[401,5423,5424,5426,5428,5430],{"class":403,"line":2969},[401,5425,2616],{"class":537},[401,5427,329],{"class":691},[401,5429,173],{"class":537},[401,5431,2978],{"class":411},[456,5433,3013],{},{"title":221,"searchDepth":222,"depth":222,"links":5435},[5436,5437,5438,5439],{"id":4983,"depth":222,"text":4984},{"id":2495,"depth":222,"text":2471},{"id":5053,"depth":222,"text":5054},{"id":5063,"depth":222,"text":5064},"Как я организовал работу с .env-файлами",{"date":3027},"\u002F2025-04-25-env-files",{"title":4975,"description":5440},"2025-04-25-env-files","M-6FNedvYYiOxKXn_a0KTLDMqbmaRGGMX-0Jo4ATH20",{"id":5447,"title":5448,"body":5449,"description":5620,"extension":225,"meta":5621,"navigation":228,"path":5622,"seo":5623,"stem":5624,"__hash__":5625},"content\u002F2025-03-25-zed.md","Редактор Zed",{"type":8,"value":5450,"toc":5607},[5451,5460,5464,5468,5471,5474,5485,5489,5492,5512,5515,5518,5521,5525,5528,5532,5539,5543,5562,5566,5570,5576,5581,5586,5590,5601],[11,5452,5453,5454,5457,5458,296],{},"На днях попробовал ",[375,5455,3042],{"href":3045,"rel":5456},[379]," — редактор кода, написанный на ",[431,5459,3050],{},[327,5461,5463],{"id":5462},"что-крутого-встроено-из-коробки","Что крутого встроено из коробки 📦",[2227,5465,5467],{"id":5466},"коллаборативный-режим","Коллаборативный режим",[11,5469,5470],{},"Можно пошарить свой текущий локальный код с тиммейтом и вместе работать в одной кодовой базе.",[11,5472,5473],{},"Работает достаточно гладко:",[31,5475,5476,5479,5482],{},[34,5477,5478],{},"можно общаться голосом;",[34,5480,5481],{},"следить за тиммейтом;",[34,5483,5484],{},"в реальном времени править взаимосвязанный код.",[2227,5486,5488],{"id":5487},"ai-ассистент","AI-ассистент",[11,5490,5491],{},"Его можно настроить на использование современных LLM. Советую посмотреть видео на главной странице редактора.",[11,5493,5494,5495,5498,5499,5501,5502,1222,5504,3344,5506,5508,5509,5511],{},"Я даже попробовал локально покрутить ",[375,5496,3100],{"href":3104,"rel":5497},[379]," и написать JSDoc для своих функций. Более или менее адекватные ответы получал только от модели ",[431,5500,3113],{},". Также пробовал ",[431,5503,3117],{},[431,5505,3120],{},[431,5507,3124],{}," с суффиксом ",[15,5510,15],{}," и без — ничего внятного они не выдали.",[11,5513,5514],{},"Надо сказать, что крутить LLM локально — очень дорогое удовольствие. Это требует много оперативной памяти, во время работы загружает процессор на все 100%, а ответа ждёшь несколько минут.",[11,5516,5517],{},"Пробовал локальные модели, потому что большинство облачных AI-провайдеров не работают в России. Всё никак не доходят руки настроить VPS со своим VPN для этих нужд. В любом случае опыт интересный.",[11,5519,5520],{},"Сейчас Zed из коробки позволяет бесплатно пользоваться ассистентом на основе Claude 3.5: разработчики только выпустили эту функцию и пока её тестируют.",[2227,5522,5524],{"id":5523},"знакомые-сочетания-клавиш","Знакомые сочетания клавиш",[11,5526,5527],{},"В Zed по умолчанию используются сочетания клавиш VS Code, а ещё можно включить Vim-режим. Сам я не сторонник Vim, но знаю, что кому-то это может быть полезно.",[2227,5529,5531],{"id":5530},"всё-текстовые-буферы","Всё — текстовые буферы",[11,5533,5534,5535,296],{},"Всё в редакторе, даже встроенный терминал, — это ",[375,5536,5538],{"href":3198,"rel":5537},[379],"текстовые буферы",[327,5540,5542],{"id":5541},"насколько-zed-расширяемый-️","Насколько Zed расширяемый ⁉️",[31,5544,5545,5553,5556,5559],{},[34,5546,5547,5548,5552],{},"Есть поддержка ",[375,5549,5551],{"href":3214,"rel":5550},[379],"практически любого языка",": разработчики Zed — создатели tree-sitter.",[34,5554,5555],{},"Расширения для Vue и PHP работают отлично. «Прострелы» работают: можно найти все ссылки на нужные символы и с помощью мультибуферов отредактировать их как угодно.",[34,5557,5558],{},"Порадовало, что для PHP можно переиспользовать лицензию Intelephense, который у меня работает в VS Code. С ним редактировать код гораздо удобнее.",[34,5560,5561],{},"Тем, иконок и всего остального тоже достаточно.",[327,5563,5565],{"id":5564},"мои-впечатления","Мои впечатления",[2227,5567,5569],{"id":5568},"плюсы","Плюсы",[11,5571,5572,5575],{},[431,5573,5574],{},"Производительность — уровень «Бог»."," Открываю триллиард файлов, и ничего не тормозит, не зависает, всё происходит мгновенно. Кажется, даже мозг начал работать быстрее.",[11,5577,5578],{},[431,5579,5580],{},"Парное программирование.",[11,5582,5583],{},[431,5584,5585],{},"Гибкая настройка.",[2227,5587,5589],{"id":5588},"минусы","Минусы",[11,5591,5592,5595,5596,5600],{},[431,5593,5594],{},"Пока сложно с SVG."," Нельзя увидеть превью прямо в редакторе. ",[375,5597,5599],{"href":3283,"rel":5598},[379],"Ишью"," под это висит уже целый год. Хорошая возможность самому законтрибьютить.",[11,5602,5603,5606],{},[431,5604,5605],{},"Git-интеграция не настолько глубокая, как у VS Code."," Разрешать конфликты пока всё ещё проще в VS Code. Но уже круто, что git blame на месте.",{"title":221,"searchDepth":222,"depth":222,"links":5608},[5609,5615,5616],{"id":5462,"depth":222,"text":5463,"children":5610},[5611,5612,5613,5614],{"id":5466,"depth":708,"text":5467},{"id":5487,"depth":708,"text":5488},{"id":5523,"depth":708,"text":5524},{"id":5530,"depth":708,"text":5531},{"id":5541,"depth":222,"text":5542},{"id":5564,"depth":222,"text":5565,"children":5617},[5618,5619],{"id":5568,"depth":708,"text":5569},{"id":5588,"depth":708,"text":5589},"Мои впечатления от редактора Zed",{"date":3326},"\u002F2025-03-25-zed",{"title":5448,"description":5620},"2025-03-25-zed","jphELBzCKpD8ddQo9F1smxZG-VzFpaRpR14_PzFpBxM",1788970693123]