Netlify → DigitalOcean Migration Plan#
2026-03-28#
Overview#
Migrate aesthetic.computer from Netlify to a new DigitalOcean droplet. Session server stays on its own droplet (157.245.134.225).
Existing DO Droplets#
| Droplet | IP | Purpose |
|---|---|---|
| session-server | 157.245.134.225 | WebSocket + UDP sessions |
| silo | 64.23.151.169 | MongoDB, feed |
| oven | 137.184.237.166 | Image/code processing |
| help | 146.190.150.173 | Help service |
| judge | 64.227.102.108 | AI moderation |
| pds | 165.227.120.137 | AT Protocol PDS (NYC3) |
| NEW: ac-web | TBD | Frontend + API server |
Phase 1: Provision Droplet#
doctl compute droplet create ac-web \
--region sfo3 \
--size s-2vcpu-4gb \
--image ubuntu-24-04-x64 \
--ssh-keys <your-key-id> \
--tag-names ac,web
Why s-2vcpu-4gb ($24/mo):
- 125 Node.js API functions (some do Sharp/image processing)
- Static file serving
- Plenty of headroom vs Netlify's function limits
Phase 2: Install Stack on Droplet#
# Caddy (automatic HTTPS, reverse proxy)
apt install -y caddy
# Node.js 22 LTS
curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
apt install -y nodejs
# Sharp dependencies
apt install -y libvips-dev
# Chromium for puppeteer (screenshot function)
apt install -y chromium-browser
# PM2 for process management
npm install -g pm2
Phase 3: Deploy Code#
# Clone repo
git clone https://github.com/user/aesthetic-computer.git /opt/ac
# Install deps
cd /opt/ac/system && npm install
# Copy env vars
cp vault/netlify-production.env /opt/ac/system/.env
# Edit .env: remove NETLIFY_DEV, add NODE_ENV=production
# Start API server
pm2 start /opt/ac/system/server.mjs --name ac-api
# Or use systemd unit (see below)
Phase 4: Caddy Config#
# /etc/caddy/Caddyfile
# Main site — handles all Netlify subdomain routing
aesthetic.computer, www.aesthetic.computer,
api.aesthetic.computer,
bills.aesthetic.computer, give.aesthetic.computer,
keeps.aesthetic.computer, news.aesthetic.computer,
papers.aesthetic.computer, pals.aesthetic.computer,
l5.aesthetic.computer, p5.aesthetic.computer,
processing.aesthetic.computer, sitemap.aesthetic.computer,
kidlisp.com, www.kidlisp.com,
buy.kidlisp.com, calm.kidlisp.com, device.kidlisp.com,
keep.kidlisp.com, keeps.kidlisp.com, learn.kidlisp.com,
pj.kidlisp.com, top.kidlisp.com,
notepat.com, www.notepat.com,
prompt.ac, api.prompt.ac, l5.prompt.ac, p5.prompt.ac,
papers.prompt.ac, processing.prompt.ac, sitemap.prompt.ac,
sotce.net, www.sotce.net,
justanothersystem.org, www.justanothersystem.org,
builds.false.work {
# Static files
root * /opt/ac/system/public
file_server
# API functions → Node.js
handle /api/* {
reverse_proxy localhost:3000
}
handle /.netlify/functions/* {
reverse_proxy localhost:3000
}
# Media proxy → DO Spaces
handle /media/* {
reverse_proxy localhost:3000
}
# Assets → DO Spaces CDN
handle /assets/* {
redir https://assets.aesthetic.computer{uri} 302
}
# SPA fallback (index.mjs function)
handle {
try_files {path} {path}/ /api/index
}
}
Note: With Cloudflare proxied (orange cloud), Caddy won't need to handle
TLS itself. Use http:// or set Cloudflare SSL to "Full" mode and let
Caddy use its auto-HTTPS with Cloudflare's origin certificate.
Phase 5: Express Adapter (server.mjs)#
Create a thin Express server that wraps existing Netlify function handlers:
// system/server.mjs
import express from 'express';
import { readdirSync } from 'fs';
const app = express();
app.use(express.json());
app.use(express.raw({ type: '*/*', limit: '50mb' }));
// Load all functions
const fnDir = new URL('./netlify/functions/', import.meta.url);
const functions = {};
for (const file of readdirSync(fnDir)) {
if (file.endsWith('.mjs') || file.endsWith('.js')) {
const name = file.replace(/\.(mjs|js)$/, '');
functions[name] = await import(`./netlify/functions/${file}`);
}
}
// Netlify event adapter
function toNetlifyEvent(req) {
return {
httpMethod: req.method,
headers: req.headers,
body: typeof req.body === 'string' ? req.body : JSON.stringify(req.body),
rawBody: req.body,
queryStringParameters: req.query,
path: req.path,
isBase64Encoded: false,
};
}
// Route: /api/:function or /.netlify/functions/:function
app.all(['/api/:fn', '/.netlify/functions/:fn'], async (req, res) => {
const fn = functions[req.params.fn];
if (!fn?.handler) return res.status(404).send('Function not found');
try {
const event = toNetlifyEvent(req);
const context = { clientContext: {} };
const result = await fn.handler(event, context);
// Handle streaming (ask.js SSE)
if (result.body && typeof result.body === 'object' && result.body.pipe) {
result.body.pipe(res);
return;
}
res.status(result.statusCode || 200);
if (result.headers) res.set(result.headers);
if (result.isBase64Encoded) {
res.send(Buffer.from(result.body, 'base64'));
} else {
res.send(result.body);
}
} catch (err) {
console.error(`Function ${req.params.fn} error:`, err);
res.status(500).send('Internal Server Error');
}
});
app.listen(3000, () => console.log('AC API on :3000'));
Phase 6: DNS Cutover#
Update all Netlify-pointing records to the new droplet IP. See vault/cloudflare-dns-records.md for the complete list.
Script to update all at once:#
#!/bin/bash
NEW_IP="<DROPLET_IP>"
CF_EMAIL="me@jas.life"
CF_KEY="<cloudflare-api-key>"
# aesthetic.computer zone
ZONE="da794a6ae8f17b80424907f81ed0db7c"
# Update root A record
curl -X PATCH "https://api.cloudflare.com/client/v4/zones/$ZONE/dns_records/<record-id>" \
-H "X-Auth-Email: $CF_EMAIL" -H "X-Auth-Key: $CF_KEY" \
-H "Content-Type: application/json" \
--data "{\"content\":\"$NEW_IP\"}"
# Update CNAMEs to A records pointing at new IP
# api.aesthetic.computer, bills, give, keeps, news, etc.
# Each CNAME → aesthetic-computer.netlify.app becomes A → $NEW_IP
# Repeat for: kidlisp.com, notepat.com, prompt.ac, sotce.net, etc.
Records to Update (35 total):#
aesthetic.computer (13 records):
- A aesthetic.computer → NEW_IP
- CNAME→A api, bills, give, keeps, l5, news, p5, pals, papers, processing, sitemap, www
false.work (1 record):
- CNAME→A builds.false.work
justanothersystem.org (2 records):
- A justanothersystem.org → NEW_IP
- CNAME→A www.justanothersystem.org
kidlisp.com (10 records):
- A kidlisp.com, www.kidlisp.com → NEW_IP
- CNAME→A buy, calm, device, keep, keeps, learn, pj, top
notepat.com (2 records):
- A notepat.com → NEW_IP
- CNAME→A www.notepat.com
prompt.ac (7 records):
- A prompt.ac → NEW_IP
- CNAME→A api, l5, p5, papers, processing, sitemap
sotce.net (2 records):
- A sotce.net, www.sotce.net → NEW_IP
jas.life (1 record — also points to Netlify):
- A jas.life → NEW_IP (currently 75.2.60.5)
Total: 36 records across 8 zones
Phase 7: Stripe Webhook URLs#
Update webhook endpoints in Stripe dashboard:
- aesthetic.computer Stripe account:
https://aesthetic.computer/api/print(and /api/mug, /api/ticket, /api/give) - sotce Stripe account:
https://sotce.net/api/ticket
These should work as-is if DNS resolves to the new server correctly.
Phase 8: Verify & Monitor#
- Test each API endpoint
- Test Stripe webhooks (use Stripe CLI to send test events)
- Test social previews (keeps-social edge function → Express middleware)
- Test media proxy (media edge function → Express route)
- Monitor PM2 logs:
pm2 logs ac-api - Check Cloudflare analytics for error rates
Rollback#
If anything breaks, repoint DNS back to aesthetic-computer.netlify.app. Netlify site stays intact as long as the account exists (even unpaid, the site config persists — you just can't deploy).
Cost Comparison#
| Item | Netlify | DO Droplet |
|---|---|---|
| Hosting | $19+/mo | $24/mo |
| Functions | included | included |
| Bandwidth | 100GB free | 4TB free |
| SSL | included | Cloudflare |
| CDN edge | included | Cloudflare |
| Build minutes | 300/mo | instant |
Net: similar base cost but much more bandwidth and no function invocation limits.