Developers

Deploy to Render

render.yaml describes the whole thing. Point Render at your copy of the repo, fill in the keys, add two domains, and connect PayPal's webhook.

What the blueprint makes

ServiceTypePlanWhat it runs
resell-storeWeb service (Node 24)starterThe Next.js app: marketplace, stores, seller app, API, docs, MCP. Health check on /
resell-sweepsWorkflow (Oregon)Render's defaultThe timed jobs in workflows/main.ts
resell-sweeps-cronCron job (Oregon)starterEvery 15 minutes, starts the sweeps on the workflow
resell-dbPostgres 17basic-256mbDatabase resell, user resell. pgvector is available and the first migration turns it on

Pick a region for the web service and database close to the workflow if you change it. Plans can be raised later in the dashboard; check Render's pricing page for what each costs.

Step by step

  1. Fork or push the repo to your own GitHub (or GitLab) account.
  2. In Render, choose New, then Blueprint, and pick the repo. Render reads render.yaml and lists the four resources.
  3. Fill in the values marked sync: false. You can leave any optional service blank and add it later; see Environment variables for what each does. At minimum: NEXT_PUBLIC_ROOT_DOMAIN and BETTER_AUTH_URL on the web service and the workflow, and RESEND_API_KEY + EMAIL_FROM so people can sign in.
  4. On the cron job, set RENDER_API_KEY to an API key from your Render account settings. RENDER_WORKFLOW_SLUG is filled in from the workflow for you.
  5. Apply. BETTER_AUTH_SECRET and CRON_SECRET are generated, and the secret is copied to the workflow.

Domains

  1. On the web service, add two custom domains: resell.store and *.resell.store (your own domain, if different). The wildcard covers every store and the api., docs. and mcp. hosts.
  2. Add the DNS records Render shows for each. Copy the exact targets from the dashboard, and remove any AAAA records: Render doesn't support IPv6 for custom domains.
  3. Set NEXT_PUBLIC_ROOT_DOMAIN=resell.store and BETTER_AUTH_URL=https://resell.store on the web service, and the same on the workflow. The root domain is baked in at build time, so trigger a new deploy after changing it.
TypeNameValue
A@216.24.57.1, or an ALIAS/ANAME to your onrender.com address
CNAMEwwwYour onrender.com address. Render redirects www to the root
CNAME*Your onrender.com address
CNAME_acme-challenge<service-id>.verify.renderdns.com, for the wildcard certificate
CNAME_cf-custom-hostname<service-id>.hostname.renderdns.com

On Cloudflare, use a CNAME on @ instead of the A record, and keep every record on DNS only (grey cloud) until Render has verified the domains and issued certificates. If you turn the proxy on afterwards, set SSL/TLS to Full. The wildcard only works while the root domain also points to Render.

Until the domains are live you can try the app on its onrender.com address, but store subdomains, the session cookie and the api/docs/mcp hosts all need the real root domain.

Outside services

  • Resend: verify your domain in Resend and set EMAIL_FROM to an address on it, on both the web service and the workflow (the workflow sends reminders and payout emails).
  • Cloudflare R2: create a bucket (default name resell-store) and an API token with read and write on it. Set R2_URL to https://{account_id}.r2.cloudflarestorage.com plus the two keys. For speed, connect a custom domain to the bucket (like files.resell.store) and set R2_PUBLIC_URL; the r2.dev address is rate-limited.
  • PayPal: set the client id (also as NEXT_PUBLIC_PAYPAL_CLIENT_ID), secret, partner merchant id and BN code, on the web service and the workflow. Then, in the developer dashboard under your app's Webhooks, add https://resell.store/api/paypal/webhook with these events, and paste its id into PAYPAL_WEBHOOK_ID:
PayPal webhook events
PAYMENT.CAPTURE.COMPLETED
PAYMENT.CAPTURE.DENIED
PAYMENT.CAPTURE.DECLINED
PAYMENT.CAPTURE.REFUNDED
PAYMENT.CAPTURE.REVERSED
CUSTOMER.DISPUTE.CREATED
CUSTOMER.DISPUTE.UPDATED
CUSTOMER.DISPUTE.RESOLVED
MERCHANT.ONBOARDING.COMPLETED
MERCHANT.PARTNER-CONSENT.REVOKED
PAYMENT.REFERENCED-PAYOUT-ITEM.COMPLETED
PAYMENT.REFERENCED-PAYOUT-ITEM.FAILED
  • The demo seller (sandbox): from your machine with the same PayPal keys, run node --env-file=.env.local scripts/paypal-demo-seller.mjs in apps/app, follow the link it prints, run it again, and set the printed PAYPAL_DEMO_SELLER_ID on the web service and the workflow. Add the demo seller and buyer logins (PAYPAL_DEMO_*_EMAIL / _PASSWORD) to the web service if you want them shown in the app.
  • The rest are just keys: ANTHROPIC_API_KEY, CHANNEL3_API_KEY, KERNEL_API_KEY, JINA_EMBEDDING_MODEL_KEY, and SUPPORT_EMAIL for escalations.

What happens on each deploy

  1. Build: corepack pnpm install --frozen-lockfile && corepack pnpm --filter app build. pnpm runs through corepack because corepack enable can't write to Render's read-only /usr/bin. The NEXT_PUBLIC_ values are baked in here.
  2. Pre-deploy: cd packages/db && npm run db:migrate applies any new migrations. If it fails, the deploy stops and the old version keeps serving.
  3. Start: cd apps/app && npm start. Render switches traffic once the health check on / passes.

The workflow and cron job install dependencies and run from source with tsx; they have no build step and no migrations of their own.

Seeding a deployed database

To fill a fresh deploy with the ten demo stores, run the seed from your machine against the database's External Database URL (on the database's page in Render). It refuses a remote database unless you say --remote. Render only accepts encrypted connections from outside, which PGSSLMODE=require turns on:

Terminal
cd apps/app
PGSSLMODE=require DATABASE_URL="postgres://resell:...@....render.com/resell" pnpm seed --remote

It uses the rest of your local .env.local: with R2 keys set there, the photos go to that bucket (use the same one as production); without, they go into Postgres. With a Jina key, the listings get embeddings. Running it again replaces only the seed's own rows.

Check it works

  • https://resell.store loads, and https://docs.resell.store shows these docs.
  • https://api.resell.store/v1/openapi.json returns the API description.
  • A store loads on its subdomain, e.g. one from the seed.
  • Signing in by email works: the link arrives, and you stay signed in when you open your store.
  • A checkout with the sandbox buyer completes, and the order shows on both sides.
  • The cron job's log shows Started resell-sweeps/offerSweep: … every 15 minutes, and the workflow's runs succeed.
  • In the PayPal dashboard, webhook deliveries to your site come back 200.

Troubleshooting

You seeCheck
Checkout says "Test checkout: no money moves yet"PAYPAL_CLIENT_ID, PAYPAL_CLIENT_SECRET and PAYPAL_PARTNER_MERCHANT_ID must all be set on the web service. If only the seller screens say it, NEXT_PUBLIC_PAYPAL_CLIENT_ID is missing or the app wasn't rebuilt
"This seller hasn't connected PayPal yet" at checkoutThe seller needs to connect PayPal on Connections, or set PAYPAL_DEMO_SELLER_ID in the sandbox
The Shopping sidekick says Coming soonKERNEL_API_KEY and ANTHROPIC_API_KEY are both needed
No emails arriveRESEND_API_KEY is set, the domain is verified in Resend, and EMAIL_FROM uses it. Without a key, emails only go to the log
Stores 404 or don't resolveThe *.resell.store custom domain and its DNS, and NEXT_PUBLIC_ROOT_DOMAIN matching the domain (then redeploy)
Signed in on the marketplace but not on a storeBETTER_AUTH_URL and NEXT_PUBLIC_ROOT_DOMAIN use the real domain, so the cookie is set on .resell.store
PayPal webhooks get 401PAYPAL_WEBHOOK_ID matches the webhook in the same PayPal app and environment
Offers never expire, payouts never happenThe cron job's RENDER_API_KEY, and the workflow's logs. As a stopgap, POST /api/cron/sweep with CRON_SECRET runs one pass
The deploy stops at pre-deployA migration failed: read the log, fix it in a new migration and push

More on the timed side in Background jobs.