Paperclip Production Migration: Embedded PostgreSQL + Local Disk → Supabase
Move your Paperclip instance from embedded PostgreSQL and local disk to Supabase Postgres and S3-compatible Storage to reduce RAM pressure and stop EBS growth.
Before You Assume It's the Database or Disk
Slowness on EC2 is not always embedded Postgres or local storage. Before a large migration, confirm the bottleneck is what you think it is. Other common causes:
- Agent fan-out: many concurrent agents stressing CPU, memory, or connection count.
- Undersized instance: too little vCPU/RAM for your workload.
- EBS performance: baseline vs burst credits exhausted, or gp2/gp3 without enough provisioned IOPS — a "sluggish" host can be I/O wait, not Postgres.
- Noisy neighbors (smaller instances / shared tenancy effects) or network path issues.
Quick triage checklist
top/htop: Ispostgres(embedded) or Java/other DB processes dominating CPU or RES memory? Highwa(I/O wait) points to disk/EBS, not necessarily "wrong database."- Disk growth: Is
~/.paperclip/.../data/storageor the Postgres data dir growing in step with user-visible slowness? - Embedded port activity: When traffic is normal, does load correlate with obvious DB activity in logs?
If the host is CPU-starved on agents alone, or IOPS-starved on EBS, rightsizing or storage tuning may fix the problem faster than a Supabase cutover. Use this guide when offloading DB and object storage is still the right move.
Why Migrate
Typical motivations:
- Embedded PostgreSQL runs alongside Paperclip and can contend for RAM and CPU when many agents run in parallel — under memory pressure the database process may become unstable.
- Local disk storage under
~/.paperclip/.../data/storagegrows with attachments. On EC2 that usually means a larger EBS volume and more frequent resizing.
Moving the database and object storage to Supabase offloads both to managed services.
Maintenance Window, Downtime, and Ordering
Downtime expectations depend on data size, network speed, and how long verification takes. Plan for at least long enough to: stop the app → dump → restore to Supabase → point config → smoke-test → (optionally) sync storage and cut over storage config. Small instances might finish in tens of minutes; large DBs or big attachment trees can take hours — measure a test dump/restore on a copy if you need a hard number.
Order of operations
- Postgres first is usual: dump embedded DB → restore to Supabase → point
DATABASE_URL/ config → verify API health and critical flows. - Storage can be the same maintenance window or a second window. If the app keeps writing local files while you
aws s3 sync, objects will drift relative to a single point in time.
Cutover pattern for storage (avoid drift)
- Stop Paperclip (or freeze uploads/attachments if you have a feature for that — most setups stop the app), then run your final sync to the bucket.
- Alternatively: sync a first pass while stopped, then stop app, second sync (incremental), then cut over
storageconfig to S3, start app, verify, then retire local storage.
One window vs split: You can split "DB migration" and "storage migration" across two windows. Document which system of record is which during the gap (e.g. DB on Supabase, files still local until window two).
Prerequisites
- A running Paperclip instance using embedded Postgres (
DATABASE_URLunset) and local storage. - A Supabase project with:
- Database connection strings (direct + pooler) from Project Settings → Database.
- A Storage bucket (S3-compatible) and API credentials with access to that bucket.
pg_dump/pg_restoreclient version: Use client tools that match or are compatible with the server PostgreSQL version. Your embedded Postgres is one version; Supabase publishes its Postgres version in the dashboard/docs. Mismatched major versions often produce opaquepg_restoreerrors. If in doubt, install client tools at the same major version as the target (Supabase) server and re-dump if needed.
Official references:
Instance Directory (Default vs Custom)
Examples in this guide use ~/.paperclip/instances/default/. If you use a non-default instance name or path, substitute your directory everywhere (config.json, .env, data/storage, db/, etc.). The layout under that instance root is the same; only the path changes.
1. Networking: Supabase and IPv6 on AWS
Supabase's database host may be reachable over IPv6-only on the free tier, while IPv4 access can require a paid add-on (pricing changes over time — check Supabase pricing).
If you cannot enable IPv6 in your VPC quickly, the practical alternative is often Supabase's paid IPv4 add-on — so you can reach the database over IPv4 without reworking the VPC. Prefer fixing IPv6 properly when you can; buying IPv4 is a trade-off some shops make for speed.
If your EC2 instance is in a VPC without IPv6, connections to an IPv6-only endpoint will fail. Fix by enabling IPv6 for the VPC and the instance:
- Associate an IPv6 CIDR with the VPC and subnets.
- Update route tables (e.g.
::/0via an Internet Gateway for public subnets, or IPv6 egress as appropriate for your design). - Assign an IPv6 address to the instance ENI.
- Ensure security groups allow outbound traffic to Supabase on the database port (and HTTPS for APIs).
Exact clicks vary by AWS console version; search AWS docs for "VPC IPv6" and follow the checklist for your subnet layout.
After IPv6 works, verify the instance can use IPv6 egress (needed when your database host is IPv6-only). curl -6 forces IPv6 — you should see your instance's public IPv6 address:
curl -6 -sS https://icanhazip.com
If that errors or hangs, fix routing/security groups before pointing Paperclip at Supabase.
2. Find the Embedded PostgreSQL Port
Paperclip's embedded server defaults to port 54329 unless you changed database.embeddedPostgresPort in config.json.
Confirm in logs on startup (look for "embedded PostgreSQL" and port=) or read:
grep -E "embeddedPostgresPort|embedded" ~/.paperclip/instances/default/config.json
Default embedded URL (password and database name are fixed for embedded mode):
postgres://paperclip:paperclip@127.0.0.1:54329/paperclip
If you changed database.embeddedPostgresPort, swap 54329 for that port.
3. Dump the Embedded Database
Stop Paperclip so the embedded cluster is not mid-write (recommended), or take the dump while traffic is stopped.
Custom format is convenient for pg_restore:
export PGPORT=54329 # your embedded port
export PGHOST=127.0.0.1
export PGUSER=paperclip
export PGDATABASE=paperclip
pg_dump -Fc -f paperclip_backup_$(date +%Y%m%d_%H%M%S).dump
You should get a single .dump file (size depends on your data; tens of MB is common for small instances).
Retain this file through a bake-in period for rollback (see Rollback, Backups, and Safety).
4. Restore into Supabase
Use the direct Postgres URI from Project Settings → Database for pg_restore (not the pooled / transaction URL), unless Supabase documents otherwise.
Supabase URIs typically require TLS: ensure the URI includes sslmode=require (or equivalent your client understands). Restore uses the direct connection; the running app should use the pooled URI when the dashboard recommends it — Paperclip is set up for pooled URLs in normal operation (prepared statements disabled where required).
export DATABASE_URL='<supabase direct pg connection uri with sslmode=require>'
Restore into a fresh database. Managed Postgres often fails with "database is not empty" if you retry into a partially loaded schema. Prefer a new Supabase project or an empty logical database created for this restore. First-time restores can also surface extension or schema surprises (extensions must exist on the target, or you drop them from the dump strategy — follow Supabase + Postgres docs for your version).
pg_restore --verbose --no-owner --no-acl -d "$DATABASE_URL" paperclip_backup_YYYYMMDD_HHMMSS.dump
- If you see role/owner warnings,
--no-owner --no-acl(as above) usually matches managed Postgres expectations. - For a dirty retry, you may need
--clean(destructive — only on a throwaway target) or to start from an empty DB again. Don't--cleanproduction you care about without understanding the blast radius. - Where to look for errors: Supabase Dashboard → Database → logs/advisors, and
pg_restorestderr in your terminal. The SQL editor can confirm table counts and spot missing extensions.
Confirm table count and a quick sanity query in the Supabase SQL editor or psql.
5. Point Paperclip at Supabase Postgres
5.1 Environment
Set DATABASE_URL for the running process. For a typical install:
nano ~/.paperclip/instances/default/.env
Example (use the pooled Postgres URI from the Supabase dashboard for the running app when available; include sslmode=require if not already in the string):
DATABASE_URL='<supabase pooled pg connection uri>'
5.2 Config file (doctor / consistency)
Update ~/.paperclip/instances/default/config.json so the database section matches hosted Postgres — especially database.mode and database.connectionString if your build expects them for health checks:
"database": {
"mode": "postgres",
"connectionString": "<supabase pg connection uri>"
}
Adjust field names to match your installed Paperclip version if they differ.
5.3 Restart and verify
Restart Paperclip (or your systemd service) and check:
curl -sS http://127.0.0.1:3100/api/health
pnpm paperclipai doctor
5.4 Retire embedded PostgreSQL
Once Supabase is verified:
- Ensure nothing sets
DATABASE_URLback to embedded mode accidentally. - You may remove or archive
~/.paperclip/instances/default/db/only after you are satisfied with backups of Supabase and a successful migration. This frees significant local RAM and disk.
6. Migrate File Storage to Supabase Storage (S3)
Local attachments live under something like:
~/.paperclip/instances/default/data/storage
6.1 Create a bucket and credentials
In Supabase, create a bucket (for example paperclip) and obtain S3-compatible endpoint, region, and access keys from the project dashboard.
6.2 Sync objects
Use any S3-compatible tool (aws s3 sync with the Supabase endpoint, rclone, etc.). Example with AWS CLI (replace region, endpoint, and bucket name):
export AWS_ACCESS_KEY_ID='...'
export AWS_SECRET_ACCESS_KEY='...'
export AWS_DEFAULT_REGION='us-east-1'
aws s3 sync ~/.paperclip/instances/default/data/storage/ s3://paperclip/ \
--endpoint-url '<supabase s3 endpoint url>'
Consistency: Prefer syncing after stopping Paperclip (or after a quiesced period plus a final incremental sync), as described in Maintenance Window, Downtime, and Ordering.
Exact endpoint and path style depend on your Supabase project; follow Supabase Storage S3 compatibility for the current URL shape.
6.3 Configure Paperclip for S3
Either use the CLI:
cd ~/paperclip # or your install path
pnpm paperclipai configure --section storage
Or edit ~/.paperclip/instances/default/config.json:
"storage": {
"provider": "s3",
"s3": {
"bucket": "paperclip",
"region": "us-east-1",
"endpoint": "<supabase s3 endpoint url>",
"prefix": "",
"forcePathStyle": true
}
}
forcePathStyle and endpoint must match what Supabase expects for S3 API calls — confirm against current docs.
The server uses the AWS SDK default credential chain. Set keys in the environment for the Paperclip process, for example in systemd:
Environment=AWS_ACCESS_KEY_ID=your-key-id
Environment=AWS_SECRET_ACCESS_KEY=your-secret
Or use a shared credentials file readable by the service user.
Security note: Values in .env and systemd Environment= are plaintext on disk. Restrict file permissions (e.g. .env readable only by the service user), rotate keys if compromised, and treat Supabase Storage keys as third-party secrets — they are not EC2 instance IAM roles. For AWS-native patterns, scope lifecycle and rotation to how your org handles long-lived API keys.
Optional env overrides:
PAPERCLIP_STORAGE_PROVIDER=s3PAPERCLIP_STORAGE_S3_BUCKET,PAPERCLIP_STORAGE_S3_REGION,PAPERCLIP_STORAGE_S3_ENDPOINT, etc.
6.4 Verify before deleting local files
Rename the old directory so Paperclip cannot fall back to it:
mv ~/.paperclip/instances/default/data/storage \
~/.paperclip/instances/default/data/storage.disabled
Exercise the UI: open issues with attachments, confirm images load from the bucket. When satisfied, delete storage.disabled or archive it offline. Keeping storage.disabled through a short bake-in period preserves a quick rollback path.
Rollback, Backups, and Safety
- Keep the
pg_dumpartifact (.dump) until you have run on Supabase confidently and have another recovery story (e.g. Supabase backups). - Keep
storage.disabled(or an offline archive) until attachments are verified under load. - Supabase as sole copy: Before deleting local
db/and old storage, consider proving you can restore from Supabase's backup (per their docs/plan) once — so "production" isn't a single manual dump on disk you've already deleted. - Rollback (minimal): Point
DATABASE_URLback at embedded only if you still havedb/and a compatible embedded setup. For storage, renamestorage.disabledback if you haven't deleted it. After you drop localdb/, rollback means restoring Postgres from your dump or from Supabase export, not flipping an env var alone.
7. systemd Reminder
If Paperclip runs under systemd, remember:
Environment=lines forDATABASE_URL,AWS_ACCESS_KEY_ID,AWS_SECRET_ACCESS_KEY, and any other vars do not come from~/.bashrc.PATHmust includenode,pnpm, and any CLIs agents invoke.
Reload after edits:
sudo systemctl daemon-reload
sudo systemctl restart paperclip
journalctl -u paperclip -n 200 --no-pager
Outcome
After migration:
- PostgreSQL runs on Supabase; the host no longer runs embedded Postgres workers (on the order of ~200 MB RAM or more may be freed, depending on load).
- Attachments live in object storage; EC2 disk growth from uploads slows or stops.
Plan limits: Supabase connection limits, disk, and egress depend on your tier. If the control plane was starved by quotas or network latency to Supabase, moving off embedded may not feel "fast" until those limits fit your traffic. Check the dashboard and plan docs for your tier before treating the migration as the complete performance fix.
You still should monitor Supabase quotas, backup strategy, and optional local backup pruning if you keep hourly dumps on disk elsewhere.