A Self-Hosted ERP for a Digital Studio's Finance Team
Sales documents and the books in one system
Read case studyThe studio I work for ran its quotations and invoices on Perfex CRM. Perfex is paid software and has no accounting, so the finance team kept the journal by hand, outside the CRM.
I replaced it with a self-hosted ERPNext that holds the sales documents and the double-entry books in one system. This note covers why I chose ERPNext, how I moved eight years of clients, quotations, invoices, and payments and checked that the totals still matched, how the cutover went, what broke afterward, and what is still open.
Finance confirmed early what they actually needed: invoicing, quotations, and accounting. For them, accounting means a chart of accounts, journal entries, the general ledger, a balance sheet and profit and loss, and an aging report for receivables.
The list of things I left out of the first version was longer:
The requirements were free and open source, self-hosted, and nothing important behind a paid edition. I compared four options:
The cost of ERPNext is its size. It ships manufacturing, stock, buying, assets, and more, and the desk shows all of it by default. A fair part of the work was hiding what the studio does not use.
The stack is Docker Compose, based on the official frappe_docker setup: ERPNext v16 pinned to an exact version, MariaDB, two Redis instances, NGINX, and the background workers and scheduler. Locally it runs on 127.0.0.1:8080 and idles at about 0.6 GB of memory.
One rule shaped everything else: configuration before code. Custom fields, print formats, module profiles, and settings come first. When something needs code, it goes into one small custom Frappe app that is built into the image on top of the official one. I never edit the frappe or erpnext apps, so an upgrade means changing the pinned version, testing it locally, and running the migration.
The custom app does two things:
The rest is configuration. The quotation and invoice PDFs use my own Jinja print formats with the company’s letter head, because ERPNext’s standard quotation formats rendered broken in wkhtmltopdf. Finance has a module profile that hides Buying, Stock, Manufacturing, Projects, and the other modules they do not need. That profile also fixed real errors: without it, workspace cards for modules finance can’t read threw permission errors on the home screen.
I built the system with a coding agent, as in my AI workflow notes. A status file in the repository holds the current state, the open items, and one log line per working session, so every session starts from the same page. Every write on the production server needed the exact commands shown first and my explicit OK.
Perfex held invoices from October 2018 onward. The importer is a small Python tool that reads a local copy of the Perfex database and writes to ERPNext through its REST API, in four commands: select, setup, run, and reconcile. Three rules held for the whole migration.
The dump is untrusted
I took the database dump read-only, restored it into a local container, and the importer never connected to a production database. Before importing, I checked that no staff account had been created since 2023, that every recent record came from one known staff user during business hours with sequential numbers, and that no text field held PHP or script payloads. Finance confirmed the recent records were real work.
Every record keeps its old ID
Each customer, contact, address, quotation, invoice, and payment gets a custom_perfex_id field, and a re-run skips anything that already has one. That made the import safe to stop. On the production server I stopped it partway through the invoices and resumed it later, and nothing was created twice.
Totals must match
reconcile compares each client in both systems: number of invoices, total invoiced, total paid, and number and total of quotations. It exits with an error on any mismatch. The final run matched 211 of 211 clients and 974 of 974 old proposals.
The mapping needed more decisions than I expected:
PFX-SINV-#####, because Frappe fills the year in a name from today’s date, not the document’s date. An invoice from 2019 would otherwise be named 2026. The PDF prints the old Perfex number.In the end ERPNext held 211 customers, 280 quotations, about 1,700 invoices, and 784 payments, and the imported invoices and payments posted their ledger entries automatically.
There is no CI/CD and no registry. I build the image on my machine from a clean commit and send it over SSH with docker save and docker load. The deploy script prints every remote command and waits for yes before it runs anything, and it refuses to run from a dirty working tree, so the image on the server always matches a commit.
The server was the one already running Perfex, shared with about 17 other services: 3.8 GB of memory with no swap, two vCPUs, and a disk at 84%. Before installing, I added 2 GB of swap and pruned the Docker build cache, which brought the disk down to 74%.
Routing took one decision. Traefik on that server runs in Swarm mode and reads labels from Swarm services, but the ERPNext stack is plain Compose, because its one-shot setup jobs do not translate cleanly to Swarm. So the ERPNext frontend joins Traefik’s network under an alias, and one small file in Traefik’s dynamic configuration folder routes the hostname to it. Traefik picks the file up without a restart.
ERPNext first ran side by side with Perfex on a local port, reached through an SSH tunnel. I imported and reconciled there. Finance preferred to test on the real address, so their testing happened after the cutover, with the rollback ready.
The cutover order was: take a final dump, compare it with the one already imported (it was identical), reconcile, stop the Perfex stack, and add the route file. Perfex’s directory and database stay on the server as an archive. I wrote the rollback down before the switch: delete the route file, redeploy the old stack, and restore its cron line. The project started on November 3, and ERPNext took over the CRM’s address on the evening of November 4.
Backups run every night at 03:00 local time through a cron job that runs ERPNext’s backup with files and keeps the newest seven sets. The server’s existing weekly offsite job already archives that folder to S3, so the offsite copy is at most a week old.
Payments in the wrong account
On production, all 784 payments posted to a customer deposit account, which is a liability. The setup wizard there had not created the bank account, so the importer picked the first account of type Bank it found. reconcile passed because it compares amounts, not accounts. I found it while comparing the local copy with production after the cutover and fixed it in one transaction: create the bank account, make it the default, and move the payments and their ledger entries. The install steps now create the bank account before any import.
Permission errors in places nobody opened
Frappe shows every number card to System Managers, even for document types they can’t read, so the home screen threw an error for an “Annual Purchase” card. A read-only permission on Purchase Order fixed it. Later, finance got “No permission for List View Settings” when changing the columns of a list, which needed a custom permission for all desk users.
Old quotations could not become invoices
Every migrated open quotation was past its valid-till date, and ERPNext refuses to invoice those. A Selling Settings option lifts the limit. Then finance asked how to make an invoice from a quotation at all: in v16 the quotation form has no button for it, and my manual pointed at the wrong one. The working path is a new Sales Invoice, then Get Items From and Quotation. I fixed the manual and decided not to add a custom button.
I wrote the finance manual in Indonesian, the language the team works in. It covers customers, quotations, invoices, payments with withheld tax, journal entries, reports, and how to correct a submitted document by cancelling and amending it.
Questions for finance went out as one list, grouped by what they block. When something couldn’t wait, I made a provisional choice, such as the chart of accounts template or the receivable account, and recorded it as needing their confirmation. Accounting changes don’t happen without that confirmation.
Before finance touched the system, I ran their main flow as their user in a script: a quotation, an invoice with withheld tax, a partial and then a full payment, and the effect on the general ledger, trial balance, balance sheet, profit and loss, and receivables. Then I rolled it back.
Reconcile the amounts, then check where they landed
A reconcile that compares totals proves nothing about which accounts the money went to. The payments matched to the rupiah and were still on the wrong side of the balance sheet.
Treat your own data as input from outside
The dump came from a system the studio owns, and it still went into an isolated container and was checked before the importer read it.
Make the import safe to run twice
Keeping the old ID on every record meant I could stop the import, resume it, and re-run it after every fix without cleaning anything up.
Write the rollback before the cutover
The same idea runs through my deployment pipeline: the way back was three written steps before the switch, and the old system stayed intact until finance signs off.
Write the manual in the team’s language and test it with them
My manual named a button that does not exist in v16, and finance hit it the first time they needed it. A manual is untested until the people who use it have followed it.
The studio now has one place where a quotation becomes an invoice, a payment, and a ledger entry, and the old data is in it, checked client by client. The accounting history before 2024 still waits on finance’s decisions.
Sales documents and the books in one system
Read case studyNext step
Tell me what you have and what you need. I usually reply within one working day with how I'd approach it.