@@ -823,6 +823,15 @@ async function runHetznerDeploy(args: {
823823 } ,
824824 } )
825825
826+ // Reconcile DNS for every site that declares a public domain. Hetzner deploys
827+ // historically had NO DNS step (Route53 reconciliation only ran on the AWS
828+ // path), so domains had to be pointed by hand. We now resolve a DNS provider
829+ // per-domain via ts-cloud's factory (Porkbun/Route53/Cloudflare/GoDaddy from
830+ // env) and upsert A records → the box IP. Non-fatal: a DNS hiccup shouldn't
831+ // fail an otherwise-successful release.
832+ if ( ok )
833+ await reconcileHetznerDns ( sites , ip , log )
834+
826835 console . log ( '' )
827836 if ( ok ) {
828837 await outro ( `Deployed to Hetzner. Your site is live at http://${ ip } :3000` , { startTime, useSeconds : true } )
@@ -834,6 +843,64 @@ async function runHetznerDeploy(args: {
834843 }
835844}
836845
846+ /**
847+ * Point every site's public domain (apex + `www`) at the Hetzner box via the
848+ * appropriate DNS provider. Providers are resolved per-domain from the
849+ * environment (Porkbun, Route53, Cloudflare, GoDaddy) using ts-cloud's
850+ * `detectDnsProvider`, so whichever registrar actually hosts the zone is used.
851+ * Idempotent (upsert) and best-effort — failures are logged, not thrown.
852+ */
853+ async function reconcileHetznerDns ( sites : Record < string , any > , ip : string , logger : typeof log ) : Promise < void > {
854+ // Collect the apex domains declared by sites (skip loopback/domain-less sites).
855+ const domains = new Set < string > ( )
856+ for ( const site of Object . values ( sites ) ) {
857+ if ( site ?. domain && typeof site . domain === 'string' )
858+ domains . add ( site . domain . replace ( / ^ w w w \. / , '' ) )
859+ }
860+ if ( domains . size === 0 )
861+ return
862+
863+ // Candidate provider configs, built from whatever credentials are present.
864+ const providerConfigs : any [ ] = [ ]
865+ if ( process . env . PORKBUN_API_KEY && process . env . PORKBUN_SECRET_KEY )
866+ providerConfigs . push ( { provider : 'porkbun' , apiKey : process . env . PORKBUN_API_KEY , secretKey : process . env . PORKBUN_SECRET_KEY } )
867+ if ( process . env . CLOUDFLARE_API_TOKEN )
868+ providerConfigs . push ( { provider : 'cloudflare' , apiToken : process . env . CLOUDFLARE_API_TOKEN } )
869+ if ( process . env . AWS_ACCESS_KEY_ID || process . env . AWS_PROFILE )
870+ providerConfigs . push ( { provider : 'route53' } )
871+
872+ if ( providerConfigs . length === 0 ) {
873+ logger . warn ( 'DNS: no DNS provider credentials found (PORKBUN_API_KEY/…); skipping DNS reconciliation.' )
874+ for ( const d of domains )
875+ logger . info ( ` Point manually: A ${ d } → ${ ip } and A www.${ d } → ${ ip } ` )
876+ return
877+ }
878+
879+ const { detectDnsProvider } = await import ( '@stacksjs/ts-cloud' ) as any
880+ logger . info ( 'Reconciling DNS records...' )
881+
882+ for ( const domain of domains ) {
883+ try {
884+ const provider = await detectDnsProvider ( domain , providerConfigs )
885+ if ( ! provider ) {
886+ logger . warn ( ` DNS: no configured provider can manage ${ domain } — point it manually: A ${ domain } → ${ ip } ` )
887+ continue
888+ }
889+ for ( const sub of [ '' , 'www' ] ) {
890+ const fqdn = sub ? `${ sub } .${ domain } ` : domain
891+ const res = await provider . upsertRecord ( domain , { name : sub , type : 'A' , content : ip , ttl : 600 } )
892+ if ( res ?. success === false )
893+ logger . warn ( ` DNS: ${ fqdn } → ${ ip } failed: ${ res . error || 'unknown error' } ` )
894+ else
895+ logger . success ( ` DNS: ${ fqdn } → ${ ip } (${ provider . name } )` )
896+ }
897+ }
898+ catch ( err : any ) {
899+ logger . warn ( ` DNS: ${ domain } reconciliation failed: ${ err ?. message || err } ` )
900+ }
901+ }
902+ }
903+
837904/**
838905 * Build an OCI container image for each site using pantry's native, daemon-less
839906 * builder — no Docker dependency. The image is built from the framework's
0 commit comments