projects / blueportal
BluePortal
A Go and Next.js commerce platform for selling digital products - Stripe checkout, licence keys, signed downloads, an event-driven worker backend and sandboxed product builds.
.`
' .` .
.:.
' ' ..
``'' .. '`..
.``,:`'``'',` .'
.',`,::,'' '` ..
`,```,,....``''. ..'` ..
...,,'.`. ..,,``..`,`,`,. ...,. '.
.`.'. ''.`. .'::''. '`,''`` . .`;` ....
.' . '. ``:` '`. ..;, .'''.'``,' ..,;;,' ..
. `. ',`..'`. ',`'`,.';. ''.'`',```` ,;``,'.. .'.
:.'::;'`` . .'`=;`,.`: '`'=-:' .`, '' .'`' . '.
, .= ,;:~,``..=:'-+-,'``''`;~!-,.,.'. ' . . '.. ','.
. + =~,--:-',:``;-`,:;-``,`.`+!i:`'``'' ''.''. .'..'``,''.
`i,-~-;,`;;:::;''`:!o,,;;,,~!,' .''' .`:::::`',,```'.'''''..
'' ++:=~-;-++=---~'.`:;l~`+='~~``':,`' '`;~~=;,;:+=::`'...'.
.,,-l~*+=-!*==-*;,;:.,;:=-;=+,~;`.'!;'. .`,,--;~;;-;**-,. .'.
,;~ll-ii*~+~+-=!~:,::---!=~+=;;'',`',' .,`'`,``::=ll=;` ...''.
.~*=ii=il!++++-!-;,`,:--;lo!i!*;:~!=;-' '`'.'''',:~io-::;l:.'. ......
:l!:=i!cci**l=*+~`;;:;;;;~ooldOl-~+=:``''`.'`''';+*=:```'~`'. .```,'''..
`+c=~*locll!li=c*--=-;-;-~;cxikOo++*~`,:,.'``''':-:~+;-,'.`:,. '`,,;-=,`''..
-l!~++loclcoc*xo~;~;,~-~~*~kbdOOkllko*;;,''',,:+=-;+=;+~;=*~,. .:-``,:,`,,`.
,!i!~!+lxxcoocox+-;-:;+++=*!cKbOWbocbOdi,`:;:;=;=:;=*~-+!!oo+:,;--:`,`'......
.~*c!*iiokdooicl!--;:~*++*l!**dkxKXddoxl=;;+*ii;::~~++--+lkkk*;=~;,,:``.
,=io*!iloxddoo*-~;:`;+--*li!iOkocckb0Ool+-~ioc!-~~-~=~-=!lXKKx*~,`,:`'.
.~+cc*icckdkxo!-~~-;;~;;-!!+ioOddxldKK0Xoi*+lc!~==;:~~==!cxd0MOl=:::'.
.:!ixlllixOkxll=;*~;~+;-;=*!libxbWWbOObdxocici**+~~;;=+*!lccxXdc=:-;`'
'.:=*icllicOdbxii~=*:+!==~=+oliiWbxKWX0KXXObdoc****===*!!+!*+=*+~-;-+=+=. .
.``=~*cooiobbbXo**~=~+l*+=**-~i=!OXkOK0MMMKKKOkcxcco!+il!*=~;;;;,`,:-~~!+'..,,.
`,`-~lxoocbbOXboi!++il*=*i!+:,~xbXKdW0KKWKWWOddkbdkbxoci!+-,`--``''',,,--,,,`
. .`,`-+ccloxOOObkoc!!loi=+il!!=:-ckK@X@@M0MW0KOObkbbxbMXk!=-,';-,'.... .';~:,.
. `:,:*lcoxkXdOkckl!iiil!llllll*-=*oW@@@@M0XdOKObdxkXKXki-::``:`'... ;;--:'
. ',`:!!ddbOOdOxoc!i!iilcxclccci*lxob0@@@@KWWW0XddkKWKbl+~;;;;,''.. .!x=:, ,'
. ,,::*cOkKbddOkociilccoxoocccocckkodKK@@WKW@@MMOOdKKObxl!+=~-;``''`iO+;''.. `;,
.' -;;-*xOdboxkbkxl!lclocooocllxkOdccdXX@@@WXO@@@MXdcxkObdli!!+;==~;`=x;' .,;::'
'' :,:~!kXXbokdbdcicclilxxlcccloOOkxxOKM@@@@KOOX@@Mdkkddxlli**ioK+;;'.,' :~:-:.
.'..'.. ,:;~ixXMbxobXkolccoccllilokxxOdkkkdOXXOkoxoodM@0dkdOdocc!*!odo;`:,'..--:*:
.``,`'..:-!lob@XkxbXxkol!ccllllcxdkdO0KdbXbdkcl!illx0@WK0Xdkbkllicc+-;;:,``;` .;'
',::,`'. -ioxK@0bbbxkXkoccclcoxxkdbOOWWbOXbdxxociiobbOM@@MOkbOkkxc!!i**~::-`
' .,;,,,,,':lob@@0k0dodOxdxoklcxxdddO0W00bkkkdddxloXbxxkdbbdkkxoli!==++=i~:`'
.,;,,,,:;;!x0@WKOMxokokxkdocxxkddbOObbdkdoxkkOdkbdcooxocodbxcobo+++-,''''
,-;;:,:~~+x@MWMMWdddddddkxxkbkkbbObbddddddkbXOOxkkxkkkkWbooxOO!;;;,''.,'
'.'. `---;--+*+lM@M@@0kbdbbddbbddbddkxkkkkdOKWXXM@W0W0W0OXX00ccoxo*=-::,`''''
..'. . :ko-~=;. .:~==!ioooc0@M@MOdbbXXbbOOOObbdkkxxkdO0W@W0W@@@@@@@MW0Xdooc!*+-:,,``.. ..
.... .' ..`;-~~,'.. `:=+~=+~;. ';~+lcoxkx0@@@0OdOX0KObOXXXbbbdddddbXW@@@MMMW@@M@@@@0OXdci+~-:,,``'```,=:`'
. ..`':-~ili*=~-:`,;=~~+=~cl-,``'.. ';:~!cooxkx0@@MXbbXK0X00KX0KOObdkdddbbbXKW@@@@@@M@MM@0XOo!+~--::,`,``,-=*:.
.` .,;-~~;```'';~~;;=!li!ldx+=~+;;:;~**=ixxxxkkK@WWObXOKKXXWW0W0XOXKK0XbbbbbbO0@@@@@W0KM@0Kdl!~---;;::;;;:::,' ,
'':' . .',`''''''`;~=~=icxxxbxi!!*+!*+*!lllxkkkkdd@0K0KOXOOObOM@WMW0W@@@@MM0KKXKW@@@@@@KW@WXdxol*+=~-;::,,,:,`'. .
. .,; . ...''',;-~*llcci===***iiiooxxxkkkdbdW@0X00MKXX0W@@@@@@@@@@@@M0KXKKW@@@@@@@MWKOxxcii!+=~--:`'.. .'.
. ' ;;` .```:;=-::;=+****!liicooxxkkkxkOKKM@@MKW@@00MM@@@MM@@@@@@@0KKXOXXK@@@@@@0OOkcl!+*=~~-:,`'... .''
. . `:,:!'.. .'.,::-++=+iillllcccokoxkkdbbbdkxdXW@@@@00@@@WW@@@MW0WM@MMMWWMKKObXW@@@@@Wbkxoc!=-~-;:,'' ...... .,.
. .,;:;:;!!*o*, .`````;~~*i!lxdObdddbOdbbKKKWW0KXKWKdloO0M@@@@@@@@@@@@MWWWWWM0XM@0M000KMK@@@@Xdkocil*=-;;:,''. .....
``'. .`,,,,:;*lili++c-::,`'`:~~=*loddkxkkdbdKWM@@@@MWWWM@@WkcxkbM@@@@@@@@@@@@M0MWWMMM@@M@KKWMWWWKKblocli!*=;,,,``' .. ..
. ... .:;`',:,=*,;==!c!=~~;;;:--;--=!icccooxkbXW@MMMMMW@@@@WK0Oxlkb0WK00W@@@@@@MMWMM@@@@@@MX0WKOXOddxol!l!=~=:,,,``'.'.....
. '''`'. :-;'`;-~!,;:;!-;-~-~;::,;:;=++!lcoooxkkOKWM0000W@@@@M0MW0OokkdxobWMM@@@@@M@@@@@MMM@@KXXXOOdkooxxli!*+~~-:,,```,'
.. ..'''' ..'``'''`--;,;,:,.':'',::;:,:::;:;--~+!illooookkbX0KKX00M@@@WM@@WKKKKKWdxlcookKM@@@@@@M0OdOKXdkkkkxooxddkcii!!++**!!+-;` .' '`'.
````'''''': `' .. .,:.. .`''``,:::;-~-~++!ilcoxocxxkdbOK0W0WW@@@WWMWMMMWMMMWMbl!===cKM@@MWKKKXbbkkxccllxkbbbxi++==+*icoc!--~;:`'.`''`,;;:'
`,;:,,```'.. ...'`,`.`:~*!!lllilcccooxxkddbW@M0MWWM@@WMM@@@@MM@@@KOc!lccokdXWM@MMM0XObdkkxkddbdkci+++=~=+**=-~+~:,'. .'```,:.
`:;;:,,`''., .'`'``,~+i!llcclcllcckdxkddbOKMK0WMWM@@@MMWM@@@@@MWKKdbkbbkol*!cdKWM@@@W00XOdkxkxcli!!*=~~~~=~-~~+=-' ''`,'. ..
'-~;:,`````.. .'`,,:;=+*ckklci!kddkoxdoxbOKK0XK00MW@@@@WW0MMMM0KXbKOW0bbdkoo!~--=*~*llcxkOK0WMWKObxci*=---;-;:-+lbc+-..' ',,-+=;`
';:,,,;`,'. '`,,;;~*!ibboccccdbxkkxokbOXXKK0WMWMM@@M0WWMM0XXKKKWbK0bbdkxoi*~-,'..',;-:;-~+!lodOdci*===~~~--=!ic+~~'. .,;-;:,
...'''=,,`. '`,=ii*xxxXbdooocd0dxkxookXK0W0WM@@@WM@@M0K0KKK000WM0W0ddkxocii+-,'.. ' '`'. ,:``'``,;-~++==-;:``. .```.
.`:,' ` .''`:-=lkdkkXWboxolcOXbxdbckxdXK0WM@@@@@@MMM0KKK000WWWWW@@kkxocli!*~,. .`~:`:`'. .. .
',;:,`.. , .`,,~~~=iOMMKM@klkocb0dxooolooxbbbXW@@@@@@@MXOKXKK000000WM@bcocli*++~:` .,''
';~-~:,`'. `=~=li!**cdX@@@XkcxO0dclccocoxkxdbdO0M@W@MW0XXXX0KKXKKK00M@Wli!!*===~:` '.
.,-+i*~;``,!' .,;=ioxxlixcodbX0@@0XWdkolclcooxxxxxxbkOKMM@@W0K000KKKKKXXKX0M@x**=--~~;,`. .
':-=i*=~-;=''... ..':-=icoxX@MxdkkbdbdXK@@Xcooclcoooocoookdxdb0MMWM@@MWW0KX0KOObXKKW0==-:-::,``.
..`-*!*~;:````:-;;-!!!xo!ccOMXXWMWobbd0@XccllodkOkcccoxoXbxdbOWKXXKWWWXXOOXXXbdOWX0Mo;-;:````'.
.,:~+*=~-::,``--::illbkllodbdbK@@dxXX@WxlicxxddKOoxxdbdkdb0WWKKK00KKXOObdkd0bbKMXKWki;'..''`'. ,`
.',;:,,,`''``'.,*iioo*iococxdXddb0MWdi!loxkkxbkxxxdkddO000KXXXXKXbbdddkkkbOOKKddWk!!, .'`''
. .'' `;=!oi*=coko***cdbb@WclbxlxkddkxxddOdkdkX0XXXK0KbkkkdxxxkxxkddkxkxbOc=* ....
..:~-~*c~-+lkb*,;~+cxdW@Od0@0bdkdbXkbM@WK@KO000XKKXKbbbxkoooxkkxxdooxcdXo++' ..
.':kc-=~::+lokd+`:=*loO0MWKOK@@@0MW0WMM@KW@MKWWKObbkdcooocoiiickocdcclob0x~=,
.';lxc!;:;=!lci**::=ilxb0MMW0K0W@@WM@MW@WKOXM0@0bkxocilxiiclli!!lxbc!c!xcbX!*'
,-*xdx!;~!*+!!==+`:*okOKW@@@M@@M@@M@@@0dxxoxbodkccccciiii!*+=*!iixkl*+liixb!=`
~!ccxi=+==*=iii~`,idoxO0MW00KKW@@@MWMMdxlcccko*llilli!*i!+~~-=!*icki~+i!*ok+=;
. ' .`-*c=!l*-;~*!i+=i=+iccbdb0KOKK0MWW0WWMKObllolxd*i*i!+icil*==*!*++!lol!!!+*oo~~-'
=di' `:-~+*lcc=`;+=~===--=*!xOdbKWXXM@WOObWWXKbOxlbl*xi*++=+!kxoxxxol++*lcOcll=*ixo;;-:
`-i+' .''`:=ii!!+-;;:-~*+~~~=!ckdkbbOW@@@0OXKOXdbKXXxi!!l!~~-~*cl*!lcci+=!lckd***+ioxl--:;
.. . ;=*++==;;-~===~--~-=!coxkK00W@@@WbObokk!xbXbc**i*--~~!i**!i*!+-~!iobo!=*+ll!!--;- '
. .*i!l+-=+=~++~~;;;;-*ioxdbO00XW@@Xcci!ll=oxkl!!*+~;-~+i*+****+=++!cbbd!~==!ii*---=
.. ;,=!iklii*~-;-;;:,;~*!lookdXWKOO@0oi*=*!==!!==+*+~-;;=ii+!!+*!+=*!ldXxi=;-+*!i+-:~+
'',~++~-;--;-~-;~!!*!!!llokWK0XX@Xo!==~~==++;~++*~;~!*++**==++*+iidKo*=;`;~~+*;;,~=
..' .,~=;:;~*+!iiii!!ilcdK0KOKObl*+-;-~~--:=+=*~~=!~=++-;;=*iilc0d*~~;`:--~*~`:;*,
':::;-~*!!*illillldOK00Okkdl+-~;;;:`,;-==*===+==~-;;-+*iliO0k+=~,,:;--+~`:,-~
.'',:-++++++*!lxdXKK0Wdxbx+-;=-:,,:,,;*il!*++=~~-;--=ll!xKbi+-~:::;~;~-,`,;+
. '.'`;;---~!xbXXW00WKdkdi-;;;-`'`,:--+ioi-;=~::-=-;*cickOo+~,,;:---:~~`'`;=
,=' '.`,:;-idWM@MMX0Wdood!~;::,,,:::-~~=i*-:--,`:---**ioxxl+:``:~~-;:=:..,-:
.~!: ..`,-ixbK0M@@WWMklok*~;,,,'`,:,,:;~~;:::=-:;;-~*+ickol~`',;~~-`--',',-; ,
'`=!lkOOXW@WM0xcok*=;,,,;,,,:-,;=~``,-;`,:;-+**ikx**-.',;+~,:=,'``:--
. . '-=+lccxdXKObooxxdic!:''-::::,`;:,.'::'`:;-++=!ic!*+:.,,-*-,,,`'``;~~
. `:-~+ciicodkl*!!ixo*=:,. .'',`':,:,.',''`:;+li*+i!=*+;``:*~~,`''''`-=' `
`,:~=oi!llxkc!*!!ic+=;`'.....',:::,```'. ':*c++++l!-+*;'`~+::: ....`-~
.`,-+lx*=!xki*!!*ii+::-,`..'',:,,'.,~,'. .',++~=+ll*=*!``,~~`,` ..'.`;~
'`;*ii+~~lkl+++*cxc=;;,;::'`,;:''.',:`.. .`:--;*l!+~=~~.`,*-',;...'.',~
. . .`;*!+=-=oxi*===+ok!i~;++==,:;``'.'''`.. .,;-~*c+~=*-`=:,:+,':; '':.`:
':;+**+-=cki+=====io-=*=!lc+:::''`'. . .',,;+l*~=*+;:=--,:,:': '':`.,
.,;-~+=;=ckx+===~==li;~**=*ci-,'. .'`~+*l=-=!:,;,-:.`.:''. ...` :
. .,;~+=;~cxbo*++~--=~;-=~:;=++-` `'`~+li==!~`,,,=''' '. .'''..
.'. .,;=+~-~iokOdo*~;;=:`,:;+;:~;;: .';==;=++l+,::::;,: .';' . .
'''.'',+i!;;!cxckObi~;~;```:-~;:~;,' . ..'.;~-::~x+;:::'':o:' `,`.. .
',.``,~+-:;=ockl*xx*=-;,,::;;''','.` .``',-----;-il-;:-`.,:.., ,`,,'. .
...``:~~:,,-ikxoi!ii*=:'',,:;;...' ' .:-+!**+~~:-++=`--..'`..' `' `,.
. '`,;;,,:;+odkc*!lccci:''`,:`.. -' .,,,-~!+~,'`=-:~; ''. .` `
.``,;::;;~=*xooxli!!il='''';-.. .` .: . .:::!*~:~:`:~-' :' ..'' ``.
.',,;;:;--~~*lcloi!+;,:'`:'.. .. `. . .-:``:~+~;:`,~' .,, . .`` .xc
.`',;:~!:;;-=+*!*++*-,'. .'. ... .' .','`:``+:''-=,''',:..
.`,,,::;:;~-;~+~=+-,`' .' .. . .'''.'`:, ~~:'`:,'''``.. .
'''````,;;,:-~;;~;;,` ..'` ','.'``'' `,:=:...', . ..
.'`````,:',:,`,:;=!=;,' .'':' -:..',. . ` '':, .. .
.'``'``'.'''..':;--~+*+' .,` :, '.. ,;,. .
.'`''`'..'.. ..,;--::;;' ''.. `. . ',`..
...''''.... '`,`''' .' . .. .'
...'... . ..'. .` ..
....... .. .`. `
...... ',.
.... . . . .. .'
. .
.
.
Overview
BluePortal is the store I run my software business on. It takes payment, issues licence keys, delivers files, emails receipts and gives buyers a dashboard to download from. Right now it sells game server plugins, mine and another developer's, but nothing in the platform is specific to that.
It is a Next.js 15 storefront in front of a set of Go services: a main API, three workers that react to events, a build service that turns a GitHub release into a sellable download, and a protection service for obfuscating .NET builds. Stripe handles money, S3 holds files, SES sends email, MySQL is the source of truth and Valkey carries events between services. Every Go service runs on Bosun, my own framework.

The problem
Selling software as one person means doing every job a shop does: taking money, handing out keys, shipping updates, answering "I paid but got nothing". Before BluePortal I sold through Ko-fi, and through PayPal payments where I wrote the receipts by hand. I had to remember who had bought what and send each buyer the right files myself, and those files went out as they were, with no obfuscation or protection.
The things I needed were:
- Payments I can trust. A paid order must become a licence exactly once, even if Stripe sends the same webhook three times or a service crashes halfway.
- Delivery without babysitting. Publishing a release on GitHub should end in a download buyers can get, without me zipping and uploading anything.
- A small blast radius. If the build pipeline breaks, the store and licence checks should keep working.
- Proper handling of personal data. It is a UK business, so GDPR and the Data Protection Act apply, and I am studying security, so I wanted to do it properly.
How it works
Services and the event bus
The API is a modular monolith split into vertical domains (payments, orders, catalog, licensing, billing and so on). Anything slow, retryable or side-effecting happens in a worker instead: payments, email and auth each have one.
Services never call each other for this. A producer writes an event into an events_outbox table in the same database transaction as the change it describes. A small relay service polls that table, pushes each row onto a Valkey stream and only then stamps it as published. If the relay dies between the two steps the row is sent again, so every consumer keeps a receipt table keyed on consumer group and event id and skips anything it has already handled. Messages that keep failing are reclaimed from dead consumers, retried, and finally land in one dead_letters table with an admin page to replay them.
There is one stream per domain (bp.payments, bp.commerce, bp.email, bp.auth, bp.builds and a few others), so related events like "order paid", "licence issued" and "receipt requested" stay in order.
Checkout and webhooks
Checkout validates the cart, creates the order and opens a Stripe Checkout session tagged with the order number. The success page just polls the order status. Nothing is marked paid until Stripe's webhook arrives.
The webhook handler in the API is deliberately thin. It checks Stripe's signature, then does three things in one transaction:
func (w *WebhookProcessor) recordAndPublish(ctx context.Context, provider dbgen.WebhookEventsProvider, providerLabel, eventID, eventType, signature string, body []byte) error { if eventID == "" { return errors.New("webhook event id missing") } tx, err := w.db.BeginTx(ctx, nil) if err != nil { return fmt.Errorf("begin tx: %w", err) } qx := w.q.WithTx(tx) rowID, err := qx.InsertWebhookEvent(ctx, dbgen.InsertWebhookEventParams{ Provider: provider, EventID: eventID, EventType: eventType, Signature: sql.NullString{String: signature, Valid: signature != ""}, Payload: json.RawMessage(body), }) if err != nil { _ = tx.Rollback() var mysqlErr *mysql.MySQLError if errors.As(err, &mysqlErr) && mysqlErr.Number == 1062 { return ErrDuplicateWebhook } return fmt.Errorf("insert webhook event: %w", err) } rawPayload, err := json.Marshal(eventbus.PaymentsWebhookRawPayload{ // ... }) // ... ev := eventbus.NewEvent(eventbus.TopicPayments, eventbus.EventPaymentsWebhookRaw, "api", rawPayload) if err := platform.PaymentsOutbox.Publish(ctx, tx, ev); err != nil { _ = tx.Rollback() return fmt.Errorf("publish raw event: %w", err) } if err := qx.MarkWebhookEventProcessed(ctx, uint64(rowID)); err != nil { _ = tx.Rollback() return fmt.Errorf("mark webhook processed: %w", err) } if err := tx.Commit(); err != nil { return fmt.Errorf("commit webhook: %w", err) } return nil}
The event row insertL11-17 has a unique key on Stripe's event id, so a redelivered webhook hits a duplicate-key errorL21-23 and the handler returns 200 without doing anything. The outbox writeL31-35 shares the transaction, so either the event is recorded and queued, or neither happens and Stripe retries. There is no window where the database says "received" but the worker never hears about it.
Turning a payment into a licence
The payments-worker parses the raw event into a typed payments.checkout.completed, and the order writer takes it from there:
held, err := w.holdIfUnderpaid(ctx, p)if err != nil { return err}if held { return nil}rows, err := w.q.MarkOrderPaidIfPending(ctx, p.OrderNumber)if err != nil { return fmt.Errorf("mark order paid: %w", err)}if rows == 0 { // apps/api (or a prior worker delivery) already marked it. w.log.Info("checkout.completed: order already paid, no downstream emit", "order_number", p.OrderNumber, "provider", p.Provider) return nil}if err := w.recordPayment(ctx, p); err != nil { // ...}// ...w.runSideEffects(ctx, p, fee, net)return w.emitOrderPaid(ctx, p)
The first checkL1-7 refuses to mark an order paid if Stripe captured less than the order now totals, or in another currency. The payment is still recorded and audited, but an admin decides what happens. Then the actual flip is a conditional update:
-- name: MarkOrderPaidIfPending :execrowsUPDATE ordersSET status = 'paid', paid_at = CURRENT_TIMESTAMP, paid_amount_cents = total_centsWHERE number = ? AND paid_at IS NULL;
The paid_at IS NULL guardL6-7 makes the database the referee. Only one delivery can change the row, and the worker only sends emails and emits order.paidL22-23 when it was the one that wonL12-17. This pattern let me move payments out of the API safely: for a while both the API and the worker processed every webhook in parallel, and the guard meant duplicates were harmless until I deleted the old path.

A licence issuer listens for order.paid and mints keys per order item, again guarded against repeats. Afterwards a reconciliation step pulls Stripe's real fee and settlement currency onto the payment row, so the admin dashboard shows what Stripe actually paid out rather than my estimate.
Delivery and builds
File bytes never pass through the API. Uploads go from the browser straight to S3 with a presigned PUT, and downloads are short-lived signed GETs minted per user after an ownership check.
For products backed by a GitHub repo, build-api is a GitHub App: a new release triggers an analysis of the repo (or a .blueportal/build.yml the developer adds), builds the .NET, Go or Node targets, optionally runs .NET outputs through protection-api, and stages a version for review before buyers see it.
Building means running someone else's code, so build-api does not do it itself. A separate runner on the host executes each command in a throwaway container:
func (c *Config) DockerRunArgs(r *ExecRequest, name string) []string { uid := strconv.Itoa(JobUID) args := []string{ "run", "--rm", "--init", "--name", name, // ... "--user", uid + ":" + uid, "--cap-drop", "ALL", "--security-opt", "no-new-privileges", "--read-only", "--tmpfs", "/tmp:rw,exec,nosuid,size=" + c.JobTmp + ",uid=" + uid + ",gid=" + uid, "--memory", c.JobMemory, "--memory-swap", c.JobMemory, "--cpus", c.JobCPUs, "--pids-limit", strconv.Itoa(c.JobPids), "--network", c.Network, "-v", r.Workspace + ":" + r.Workspace + ":rw", "-v", r.CacheVolume() + ":/cache:rw", "-w", path.Clean(r.Dir), } // ...}
The job runs as a user with no host account, no capabilities and a read-only rootL7-10, with hard memory, CPU and process limitsL12-15. Its network only reaches the internet, not BluePortal or the host, and it can only see its own workspace and a per-product cacheL17-18. build-api talks to the runner over a unix socket and never gets the Docker socket.
Deployment
Production is one Docker Compose stack behind Caddy, with Cloudflare in front. Caddy handles TLS, only trusts the client IP header when the request really came from Cloudflare, and stamps a request id that the Go services reuse. It also writes a JSON access log that a small ingester service reads, so the admin request monitor can show gateway 502s that never reached the app at all. The build runner is the only thing installed on the host directly.
Security considerations
The basics are built in: every query goes through sqlc with bound parameters, passwords use bcrypt, all stored tokens are hashed, refresh tokens rotate with reuse detection, admins must use TOTP and re-confirm it for sensitive actions, and every admin change writes an audit row. Role checks live in middleware rather than in handlers.
In July 2026 I did a security and GDPR / UK DPA 2018 review of the whole platform and turned it into delivery waves. From that came security headers and a global rate limit in the API, AES-256-GCM encryption for secrets at rest, a "download my data" export, retention sweeps for logs that hold personal data, an erasure flow that also scrubs order snapshots and provider payloads, one-click unsubscribe, and recorded consent for analytics (the banner in the screenshot above).
In September I ran a second, adversarial audit of the API, the payments worker, the web app and the new build runner. Every finding had to survive a second reviewer trying to disprove it. That produced 19 issues, mostly business-logic ones: ownership checks on upgrades, prices that could change during an open checkout, subscription purchases getting the wrong kind of licence, server-side request forgery through developer webhooks, account linking edge cases and open redirects. Each got its own branch and pull request, and they shipped together. Several of the findings were gaps left by my own fixes earlier in the same pass, which only showed up because the fixes were reviewed as hard as the original code.
History (v1 -> v2)
v1 (February to May 2026) started as a client portal for project releases and grew into a multi-developer marketplace: Go with Fiber, a React frontend, PayPal and Coinbase, revenue splits, studios and developer payouts, plus DLL licence injection. By May it had become hard to keep up to date or keep track of. Releases were still a manual job, when what I wanted was a single button on a project's page that put a new version out to buyers, and I wanted to stop using Coinbase.
v2 started on 17 May 2026. I tagged v1, cleared the branch and rebuilt with a much smaller scope: one seller, Stripe, a Next.js frontend, and screens designed before they were built. To keep v1's users, orders and licences, the API runs an idempotent "renewal" migrator on boot. It detects v1-only tables, applies each step once, records the SQL needed to undo it, and refuses to run the first time until a backup is confirmed.
From there it grew back in a more controlled way:
- June: issue #13, "pub sub instead of hard code", became the outbox and Valkey event bus, and payments moved to the worker.
- July: first production deploy. Other developers came back as a proper role, with Stripe Connect payouts and an approval queue, which also removed PayPal from the runtime. Subscriptions, commissions and an affiliate programme followed.
- September: a developer API with keys and signed webhooks, the request monitor, a split of the API into vertical domains, and the move from Fiber to Bosun. That migration went one service at a time, starting with the relay as a spike, then the workers, build-api, protection-api and finally the API itself. A custom Valkey driver for Bosun's event module stayed wire-compatible with the old bus, so old and new services could run side by side. Fiber is now gone from the codebase and 165 Go files across nine modules use Bosun.
Outcome
BluePortal is live at blueportal.app and handles real orders from checkout to download with no manual steps. Since launch, two other developers besides me have started selling through it and taking payments on it. It has also grown to support several file storage backends for different systems, and more languages and platforms.
What I learned:
- Let the database referee. Unique keys and conditional updates did more for correctness than any amount of careful code. Once every handler was safe to run twice, retries and crashes stopped being scary.
- Cut scope, then add back on purpose. v2 started single-seller, and developers came back later with a much cleaner model than v1's splits.
- Migrate in place, one unit at a time. Both the payments move and the Bosun move worked because old and new could run together.
- Audit your fixes too. Some of the most useful findings were holes in patches I had just written.
- Using my own framework in production finds its rough edges fast. BluePortal is Bosun's biggest user, so it is where Bosun gets tested hardest.
Next: upgrading to Bosun v0.7.0 and moving the Valkey driver upstream into Bosun, a "most purchased" recommendation rail, live build logs in the dashboard, and looking at white-labelling the platform for other sellers.