Source control in n8n is a licensed feature. On a Business or Enterprise self-hosted instance you connect the instance to one branch of a Git repository, push workflows to it and pull them on another instance; n8n calls the result "environments". On the community edition none of that exists, workflow history goes back 24 hours, and a dev instance is whatever you build yourself. This article covers both halves: what the paid feature does, in one section, and then the community setup I run, which is two instances, a nightly export to Git and a few naming rules that make promotion between them uneventful. The pipeline that pushes a file from the repo to prod is its own article, the CI/CD guide for n8n with GitHub Actions, so this one stops where that one starts. Read that one second.
Built-in source control and environments (Business and Enterprise)
Set up under Settings, Environments. You give n8n a repository URL and it generates a deploy key (ed25519 by default, N8N_SOURCECONTROL_DEFAULT_SSH_KEY_TYPE changes it) that you add to the repo, or you use HTTPS with a token. Each instance is bound to exactly one branch. Push from an instance writes its workflows, credential stubs (name and type, no secrets), variables and tags to that branch as files; pull on another instance reads them in. Merging between branches happens in Git, outside n8n. A dev instance on develop and a prod instance on main with a pull request between them is the layout the source control documentation describes, and you can mark prod as a protected instance so nobody edits it directly. Instance owners and admins can push and pull; project admins can push. That is the whole feature, and if you have the licence it's the right answer and you can skip to the naming section.
Workflow history in the community edition
Every save creates a version. On community you can open versions from the last 24 hours, restore one, clone it to a new workflow, open it in a new tab or download it as JSON. After 24 hours they're gone. Named versions, which n8n never prunes, are Business and Enterprise; the full unlimited history is Enterprise. The workflow history page has the exact matrix. Since 2.13 there's also a visual comparison between two versions in the history panel, which is the thing I use most, and it works on the 24 hour window like everything else.
So on community, Git is your history. Not because a repo is a nicer place for it, but because anything older than yesterday doesn't exist otherwise.
Run a separate n8n instance per environment
The minimum is two: dev and prod. Staging becomes a third once more than one person edits workflows, since dev then stops being a stable place to test. Each is a full n8n instance with its own database, its own encryption key and its own hostname. On a single VPS that means two Docker Compose projects side by side, separated by project name:
/opt/n8n-prod/compose.yaml -> https://n8n.example.com
/opt/n8n-dev/compose.yaml -> https://n8n-dev.example.com
Compose namespaces containers, networks and volumes by the directory name, so the two n8n_data volumes become n8n-prod_n8n_data and n8n-dev_n8n_data without you doing anything. The things that have to differ between the two .env files:
N8N_HOST=n8n-dev.example.com
N8N_EDITOR_BASE_URL=https://n8n-dev.example.com/
N8N_WEBHOOK_URL=https://n8n-dev.example.com/
N8N_ENCRYPTION_KEY=<a different key>
DB_POSTGRESDB_DATABASE=n8n_dev
EXECUTIONS_DATA_MAX_AGE=48
Both instances can share one Postgres container if you like, each with its own database and user, though I'd rather keep dev on its own container so a runaway test can't touch prod's disk. The reverse proxy gets a second site block pointing at the dev container's port, and the webhook URL variables for n8n behind a proxy apply to each instance on its own hostname. Pruning tighter on dev is a habit; nobody needs two weeks of test runs.
Separate keys is deliberate. With different N8N_ENCRYPTION_KEY values, a credentials export from dev can't be imported on prod, which is what you want: the prod Stripe key never lives on the box people experiment on. It also means each environment's credentials are created once, by hand, on that instance. Twenty minutes per environment and you're done.
Two instances on one VPS is fine for a team of a few people. When dev workflows start eating CPU that prod needs, move dev to its own small server; the n8n VPS template deploys a second instance with Docker and Caddy already in front, which is quicker than copying the prod compose and editing hostnames, and a dev box doesn't need more than the smallest plan. Keep both on the same n8n version, or workflow JSON from a newer dev will carry node versions prod doesn't know yet.
A workflow that exports itself to Git every night
Two ways to get workflow JSON into a repo on a schedule. The first is a cron job on the server that runs n8n export:workflow --backup through docker compose exec, copies the files into a checkout and commits; it's six lines of bash and it lives next to the backup script in the n8n backup guide. The second, which I prefer because it needs nothing on the host, is an n8n workflow that does it from inside:
1. A Schedule Trigger at 02:00.
2. The n8n node (the one that talks to the instance's own public API, using an n8n API credential with a key from Settings) with resource Workflow, operation Get Many, returning every workflow as an item.
3. A Code node that turns each item into a file path and a body:
return $input.all().map(item => {
const wf = item.json;
const slug = wf.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
const { id, name, nodes, connections, settings } = wf;
return {
json: {
path: `workflows/${id}-${slug}.json`,
content: JSON.stringify({ id, name, nodes, connections, settings }, null, 2) + "\n",
},
};
});
4. A GitHub node, resource File, operation Edit, with the path and content from the item and a commit message like nightly export from prod. Edit fails on a file that doesn't exist yet, so an IF on the error output routes new workflows to a second GitHub node set to Create. Point the credential at a repo-scoped token with contents write and nothing else.
The Code node keeps only the five fields the API accepts back, so the file in the repo is exactly the body the deploy pipeline will send. GitHub records a commit per file, which is noisier than one commit per night; I'm fine with that because the history per workflow is what I read. If you'd prefer a single commit, the cron route with a real git commit is the way. The n8n node documentation lists the operations, including Publish and Unpublish, which the promotion section below leans on. Get Many pages its results, so switch the node's return-all option on or you'll export the first page and nothing else.
One number from running this for a while: our prod instance has 31 workflows and the nightly run takes 9 seconds. Most nights it changes nothing, and the GitHub node returns the same SHA, which the Edit operation treats as a no-op commit. I haven't checked what happens the night a workflow is deleted on prod; the file stays in the repo, I assume, and I've never cleaned one up because it hasn't come up yet.
Naming rules that keep workflow JSON portable
Same name everywhere. A workflow called Billing / invoice reminders is called that on dev, staging and prod, with no [DEV] prefix, so the file that leaves dev is the file that lands on prod without an edit. Environment belongs in the hostname and the credentials, never in the workflow.
Same credential names everywhere too, and this one matters more. A node stores the credential it uses as an id plus a name. When you import or deploy a workflow to an instance where that id doesn't exist, n8n looks for a credential with the same name and type, and if there's exactly one it swaps it in. Different ids across environments are therefore harmless as long as the name and type match, and a credential named Stripe of type Stripe API on both instances means the workflow resolves on prod even though prod's Stripe credential is a different secret. Two credentials with the same name and type on one instance breaks the lookup, so don't do that.
Folders are cosmetic and per instance. Use them, but don't rely on them to carry meaning across environments, since a deploy through the API lands the workflow wherever the target decides.
Promote a workflow from dev to prod
The sequence, once the repo exists:
Build and test on dev. Open the workflow's history and compare the current version to the one from before you started, to see the whole change in one view. Wait for the nightly export, or run it by hand, so the repo has the new JSON. Open a pull request from that change; the diff is the workflow. Someone reads it. Merge, and the deploy pipeline sends it to prod and re-publishes.
The publish state is per instance. A workflow published on dev is published on dev; deploying it to prod doesn't publish it there unless the pipeline does, and the same is true in reverse, so you can keep a workflow unpublished on dev to stop a Schedule Trigger firing twice a day from two places. On dev I unpublish everything with a schedule and only publish webhook workflows I'm testing, which is a manual discipline that the paid environments feature handles for you with the protected instance flag. The pipeline uses the API's publish behaviour; from a terminal it's n8n publish:workflow --id=<ID> and n8n unpublish:workflow --id=<ID> (or --all for unpublish, which is handy when you clone prod's database onto a new dev instance and want nothing running until you've looked at it).
Per-environment values without the Variables feature
Variables ($vars.SOMETHING in expressions) are Business and Enterprise. On community, three options for "this value is different on prod":
A Set node at the top of the workflow named Config, with the values as fields, referenced downstream as {{ $('Config').item.json.slack_channel }}. It's in the workflow JSON, so it deploys with it and differs between environments only if you edit it after deploy, which defeats the point. Good for values that are the same everywhere.
A Data Table named config on each instance with key and value columns, read at the top of the workflow with the Data Table node. The table lives in the instance database, so prod's rows and dev's rows are seperate and the workflow JSON is identical. Ours holds the Slack channel to post to, the base URL of our billing system and a boolean that turns real emails on. Data tables are a 2.x feature available on community.
Environment variables through $env, after setting N8N_BLOCK_ENV_ACCESS_IN_NODE=false. It works and it's the option I don't take, because it opens every variable in the container to every workflow editor, and the encryption key is one of those variables. Leave the block on.
Secrets are none of the above. They go in credentials, per instance, and the name rule takes care of the rest.

