Folk spør meg stadig hvorfor jeg ikke bare brukte WordPress, eller Ghost, eller ærlig talt hvilken som helst av de tolv plattformene som ville fått meg til å publisere et første innlegg i løpet av timen i stedet for i løpet av året. Det korte svaret er at jeg ville lære noe, og det lange svaret er egentlig hele dette innlegget.
hva jeg faktisk prøvde først
Jeg så faktisk på de åpenbare alternativene før jeg bestemte meg for å ikke bruke dem. WordPress føltes som feil verktøy for det jeg egentlig ville ha, mye plugin-overflate for ting jeg ikke trengte, og en PHP-stack jeg ikke hadde noen som helst interesse av å lære meg bare for å drive en blogg. Ghost var nærmere, genuint fint rett ut av boksen, men det er fortsatt noen andres plattform under panseret, og hele poenget med dette prosjektet var aldri egentlig "ha en blogg", det var "bygge noe ekte nok til at jeg ikke kunne bløffe meg gjennom det". En hostet plattform ber deg ikke om å forstå hvordan innlogging fungerer, eller hvordan opplastinger lagres, eller hva som skjer når to hundre personer treffer samme side samtidig. Jeg ville ha tingen som stiller de spørsmålene, ikke tingen som svarer på dem for meg før jeg engang rekker å tenke på å spørre.
Jeg hadde plukket opp biter av Go og SvelteKit en stund på det tidspunktet, mest gjennom små skript og halvferdige tutorial-prosjekter, og ingenting av det føltes som det ble til noe ekte. En blogg virket som en god unnskyldning. Liten nok til faktisk å fullføre, med nok ekte deler (kontoer, en database, noe som vender ut mot verden) til at jeg ikke kunne bløffe meg gjennom det. Så i stedet for å installere noe som allerede fungerte, bestemte jeg meg for å bygge tingen som ville lære meg mest, selv om jeg visste det ville ta mye lengre tid.
formen det faktisk endte opp med
Fem tjenester, én docker compose-fil, som kjører på én boks i leiligheten min. Ingenting av det var planen dag én. Det er bare der en haug med små avgjørelser, de fleste tatt fordi den forrige tilnærmingen brøt sammen, endte opp.
services:
postgres:
auth:
blog:
frontend:
caddy:hvorfor akkurat Go og SvelteKit, og ikke noe mer populært
Ingen av valgene handlet egentlig om å velge "det beste" verktøyet. Go, fordi jeg ville ha noe kompilert og kjedelig på den gode måten, et språk som ikke har femten forskjellige aksepterte måter å strukturere en liten webtjeneste på, og fordi standardbiblioteket alene tar deg mesteparten av veien til en fungerende HTTP-server uten å måtte hente inn et rammeverk som standard. SvelteKit, mest fordi det var det nyeste på listen min på den tiden og jeg ville at frontend-halvparten av dette prosjektet også skulle være et læringsprosjekt, ikke bare et sted å skru på et kjent verktøy oppå de to Go-tjenestene. Django eller Express hadde fått meg til en fungerende side raskere, begge språk jeg allerede kunne rimelig godt fra før. Raskere var ikke egentlig målet her.
Docker compose fremfor noe mer utbrodert var en tilsvarende uglamorøs avgjørelse. Dette kjører på én boks. Kubernetes løser problemer jeg ikke har, koordinere mange maskiner, rulle ut driftssikre deployer uten nedetid på tvers av en flåte, ingenting av det gjelder for én server som står i leiligheten min. En compose-fil jeg kan lese fra start til slutt på under et minutt slo et mer "ordentlig" oppsett som ville tatt uker å lære uten noen faktisk gevinst på denne skalaen.
auth-tjenesten, forklart enkelt
Én liten Go-tjeneste, én jobb: vite hvem du er, og bevise det for alt annet uten at noen andre må stole direkte på et passord. Registrer deg, logg inn, få et kortlevd access-token pluss en lengre-levd refresh-cookie, slik at du forblir innlogget uten at selve access-tokenet lever evig hvis det noen gang skulle lekke.
func (s *Server) Login(w http.ResponseWriter, r *http.Request) {
var req loginRequest
if err := decodeJSON(w, r, &req); err != nil {
writeDecodeError(w, r, err)
return
}
user, err := s.Queries.GetUserByUsername(r.Context(), req.Username)
if err != nil {
writeError(w, r, http.StatusUnauthorized, "invalid_credentials", "invalid username or password")
return
}
ok, _ := password.Verify(req.Password, user.PasswordHash)
if !ok {
writeError(w, r, http.StatusUnauthorized, "invalid_credentials", "invalid username or password")
return
}
s.issueTokensAndRespond(w, r, user, http.StatusOK)
}Passord lagres aldri direkte, opplagt nok, bare en hash, og en spesifikk hash designet for å være treg med vilje (argon2id), slik at selv om databasen skulle lekke, er brute-forcing tilbake til ekte passord dyrt nok til at det ikke lønner seg å prøve i stor skala.
blog-tjenesten, forklart enkelt
Den andre Go-tjenesten, en helt separat en, på sin egen database, eier innlegg, kommentarer, tagger, kategorier, mediemetadata. Den vet ikke noe om passord selv, den stoler bare på et token auth-tjenesten har utstedt, på samme måte som en dørvakt stoler på et armbånd uten å måtte personlig verifisere ID-en din på nytt ved hver eneste dør inne på stedet.
r.Route("/admin/posts", func(r chi.Router) {
r.Use(authmw.RequireCapability(verifier, matrix, capability.PostsManage))
r.Get("/", s.AdminListPosts)
r.Post("/", s.AdminCreatePost)
r.Patch("/{id}", s.AdminUpdatePost)
})Å holde dette som en andre, separat tjeneste fra auth føltes som overkill i starten, ekstra nettverkshopp uten noen åpenbar grunn, helt til jeg faktisk ville resonnere rundt "hva kan gå galt med kontosystemet" helt separat fra "hva kan gå galt med innleggssystemet". To mindre, kjedelige problemer i stedet for ett stort skummelt et.
SvelteKit-frontenden, forklart enkelt
Dette er delen alle som besøker siden faktisk ser, serverrenderte sider, et innloggingsskjema, innleggseditoren, alt sammen. Den snakker ikke direkte med de to Go-tjenestene fra nettleseren, den proxyer alt gjennom sine egne serverside-ruter først, noe som viste seg å bety mye mer enn jeg forventet når jeg faktisk kom til reverse-proxy- og rate limiting-kampene lenger ned.
async function handle(event: RequestEvent): Promise<Response> {
const upstreamPath = event.url.pathname.replace(/^\/api/, '') || '/';
const target = new URL(upstreamPath + event.url.search, env.BLOG_INTERNAL_URL);
const upstream = await forwardRequest(event, target, extraHeaders);
return new Response(upstream.body, { status: upstream.status });
}postgres, forklart enkelt
To separate databaser, én for auth, én for bloggen, ikke ett delt skjema med alt blandet sammen. Det føltes som mer oppsett uten grunn i starten, og føltes så opplagt riktig første gang jeg ville sikkerhetskopiere eller migrere den ene uten å røre den andre i det hele tatt.
minio, forklart enkelt
Opplastede bilder og avatarer bor ikke inne i Postgres, de bor i MinIO, som snakker samme API som Amazon S3 men kjører som bare enda en container på min egen boks. Jeg likte dette spesifikt fordi det betyr at å bytte til en ekte cloud-bucket senere, hvis jeg noen gang skulle trenge det, er en konfigurasjonsendring og ikke en omskriving. Om jeg noen gang faktisk kommer til å trenge det er et separat spørsmål. Å ha muligheten kostet nesten ingenting på forhånd.
caddy, forklart enkelt
Det eneste i hele stacken med en offentlig port. Alt annet snakker bare med hverandre over det interne docker-nettverket, ingenting nåbart direkte utenfra bortsett fra gjennom Caddy. Den terminerer TLS, så jeg trenger aldri tenke på sertifikatfornyelse, og det er den som faktisk håndhever hver størrelsesgrense og rate limit resten av dette innlegget er i ferd med å klage over å ha satt feil første gang.
handle /api/admin/* {
request_body {
max_size 5MB
}
reverse_proxy frontend:3000
}Det er omtrent formen på det. Nå til delene som faktisk gikk galt, hver av dem verdt sin egen ærlige seksjon i stedet for ett vagt "og så fikset jeg noen bugs"-avsnitt.
opplastingsstørrelse-taket-sagaen
Opplastinger var den første ekte veggen. Jeg kunne laste opp et lite testbilde helt fint, prøvde så et ekte bilde rett fra telefonen min og fikk en kryptisk feil uten noen nyttig melding i det hele tatt, bare en mislykket forespørsel med ingenting nyttig i nettleserkonsollet. Det viste seg at det finnes et størrelsestak på forespørselen i Caddy foran appens eget tak, og jeg hadde satt det altfor lavt uten egentlig å forstå at to separate lag begge måtte være enige om hva "for stort" betyr.
# before: media uploads fell under the same small default as everything else
handle /api/admin/* {
request_body {
max_size 2MB
}
reverse_proxy frontend:3000
}Caddys eget standardnivå er ikke i nærheten av generøst nok for et moderne telefonbilde, og før jeg faktisk gikk og lette, hadde jeg ingen anelse om at det i det hele tatt var en separat grense fra det Go-tjenesten selv håndhevet.

Det tok en kveld med forvirret stirring på logger før jeg i det hele tatt fant hvor grensen bodde, langt mindre hvorfor. Halve fiksen kom fra en fem år gammel forumtråd, ikke dokumentasjonen, noen som beskrev nøyaktig samme symptom på et helt urelatert prosjekt, som er hvordan jeg faktisk fant ut at jeg skulle lete i Caddy-konfigurasjonen i stedet for å anta at buggen bodde i min egen Go-kode hele tiden.
handle /api/admin/media* {
request_body {
max_size 1100MB
}
reverse_proxy frontend:3000
}En separat, mye større grense bare for medieopplastingsveien spesifikt, alt annet i appen holder seg på den mindre standarden. Så snart begge lagene faktisk var enige med hverandre, begynte ekte bilder å laste opp uten klager.
kommentarer og innlogging som tok evigheter, lengre enn jeg forventet
Jeg tror jeg antok at "bruker skriver passord, server sjekker det" var stort sett hele funksjonen. Det er faktisk en overraskende mengde under der: hashe passordet ordentlig, utstede et kortlevd token pluss et lengre-levd et for å forbli innlogget, og så delen som faktisk fikk meg, rate limiting bak en reverse proxy. Kommentarer viste seg å ha sin egen mindre versjon av den samme lærdommen, et kommentarskjema virker trivielt helt til du også håndterer spam, rate limits per bruker, og moderering, ingenting av det dukker opp i en tutorials "legg til en kommentarboks"-eksempel.
å lære hva en reverse proxy faktisk gjør
Hvert innloggingsforsøk så ut som det kom fra samme IP, fordi Caddy var det eneste som noen gang snakket direkte med appen min, og jeg hadde ikke fortalt den at den skulle stole på headeren som faktisk sier hvem den ekte besøkende var. Den tok lang tid å i det hele tatt forstå som en bug, siden symptomet så ut som "rate limiting er ødelagt" heller enn "jeg forstår faktisk ikke hva en reverse proxy gjør med en forespørsel". Reverse proxy er et av de begrepene jeg hadde brukt konstant i årevis uten egentlig å vite hva det gjorde mekanisk, og å bygge dette fikk meg faktisk til å sette meg ned og finne det ut i stedet for å bare nikke neste gang noen nevnte en.

rate limiting som bet meg spesifikt
Når jeg først forsto reverse-proxy-problemet, var selve fiksen liten, stol på nøyaktig ett hopp av forwarded-for-headeren, matchende den ene proxyen jeg faktisk har foran appen. Men å komme dit betydde først å legge merke til at ti mislykkede innloggingsforsøk fra ti forskjellige ekte personer på en eller annen måte alle havnet i samme bøtte, bli mistenksom på at noe var fundamentalt galt med hvordan jeg identifiserte hvem en forespørsel egentlig kom fra, og først da innse at forespørselens tilsynelatende kilde-IP var Caddys egen container-adresse hver eneste gang, for hver eneste besøkende, fordi Caddy virkelig er det eneste som noensinne åpner en socket til frontend. Hver besøkende som deler én identitet, fra appens synspunkt, er omtrent så ødelagt som rate limiting kan bli uten å faktisk kaste en feil for å fortelle deg det.
redesignet, generisk først, så faktisk Lovund
Den første ekte versjonen av denne sidens design var, sett i ettertid, en bloggmal med serienumrene filt bort. Blåaktig aksentfarge, standard systemfont, avstand som lignet på alle andre raske sider hvem som helst bygger på en helg. Den fungerte. Den kunne også vært hvem som helst sin blogg, noe som begynte å plage meg mer jo lenger jeg så på den, når selve funksjonaliteten var solid nok til at designet var det som stakk seg ut som uferdig.
For noen uker siden gjorde jeg om hele det visuelle. Grønn aksentfarge, en serif for brødteksten, tettere marger enn standarden jeg startet med. Ikke fordi den gamle versjonen var ødelagt. Den lignet på alle andre raske bloggmaler, og jeg ville ha noe som føltes som et faktisk valg i stedet for hva et starter-kit kom med. (Jeg sa også at jeg ikke skulle røre CSS-en igjen når redesignet var ferdig. Det varte i omtrent fire dager.)
Den grønne fargen spesifikt var ikke tilfeldig. Å bo et sted så grønt som Lovund blir om sommeren, mose og lier og vannet som gjør den bestemte dype fargen det gjør under en overskyet himmel, det føltes som aksenten denne siden faktisk skulle hatt fra starten av i stedet for hva enn generisk blå-tilstøtende farge jeg hadde defaultet til uten å tenke på det i det hele tatt. Når jeg først valgte den, fulgte resten av redesignet mest av seg selv av å gjøre alt annet stille nok til at den grønne faktisk stakk seg ut i stedet for å konkurrere med seks andre farger om oppmerksomhet.
en liten funksjon jeg bare la til etterpå
Én ting blog-tjenesten gjør som jeg ikke opprinnelig planla for: planlagt publisering. Et innlegg kan sitte som utkast med en fremtidig publiseringstid, og en bakgrunnsjobb sjekker hvert tredje sekund etter noe hvis tid har kommet, flipper det til live, og stempler det faktiske publiseringstidspunktet i det øyeblikket i stedet for når enn jeg tilfeldigvis trykket lagre. Det er en liten mekanisme, én tikkende loop og to SQL-setninger under, ingenting som trengte en jobbkø eller noe fancy. Jeg bygde det mest slik at jeg kunne skrive noen innlegg i ett strekk på en rolig kveld og ikke få alle sammen live samtidig, spre dem ut i stedet uten å måtte huske å komme tilbake og trykke publiser på hver enkelt individuelt.
selvhosting-realiteter, siden den delen heller ikke er gratis
Å drive dette selv i stedet for å betale en plattform betyr at hver eneste nedetid er min å legge merke til og min å fikse, på min egen tidsplan, vanligvis oppdaget av meg mens jeg prøver å sjekke noe heller enn noen form for varsel. Backup betyr mer her enn de ville gjort på en administrert plattform, siden det ikke er noen leverandør som stille holder en kopi av databasen min et sted jeg ikke trenger å tenke på. Jeg har begge Postgres-databasene dumpet hver natt og pushet et sted vekk fra denne faktiske boksen, fordi et enkeltmaskin-oppsett også er et enkeltpunkt for feil, og å finne det ut under en faktisk diskfeil ville vært en genuint dårlig måte å lære lærdommen på.
Det som faktisk går i stykker, i praksis, er mindre og mer kjedelig enn jeg forventet før jeg begynte: en container trenger av og til restart etter en host-reboot jeg glemte å ta høyde for, diskplass kryper opp saktere enn jeg hadde gjettet men kryper likevel opp, og den ene ekte skremmen så langt var en feil i tillatelser på Postgres-datamappen etter å ha flyttet til ny lagring, som tok en kveld å ordne opp i og gjorde meg kort sagt veldig glad for at backupene faktisk fantes i stedet for å være noe jeg hadde tenkt å teste og aldri gjorde.
Å restarte en tjeneste er én ting når selve boksen står noen meter unna meg. Det er en helt annen følelse å vite at hvis hele maskinen skulle dø helt, er den faktiske gjenopprettingsplanen en ny boks, en gjenopprettet databasedump, og hvor lang tid det enn tar å få docker compose i gang igjen fra bunnen av, sannsynligvis mesteparten av en kveld hvis alt jeg har skrevet ned om oppsettet faktisk stemmer og ingenting stille har driftet siden sist jeg sjekket. Jeg har ikke testet det fulle scenarioet ennå, som er akkurat den typen ting jeg vet jeg burde gjøre på en rolig helg heller enn å finne det ut på den harde måten under en faktisk nedetid.
hva som er neste
Ekte moderasjonsverktøy for kommentarer, siden jeg akkurat nå gjør det for hånd direkte gjennom databasen, som er greit på den nåværende bittelille skalaen men ikke vil forbli greit. Faktisk skrive mer, nå som "bygg plattformen"-unnskyldningen for ikke å skrive noe stort sett har gått tom. Og sannsynligvis, en dag, faktisk gå og klatre et av fjellene jeg fortsetter å se på fra fergen i stedet for bare å skrive om å ønske det.