Self-hosting
Moving an instance
How to move a whole Flowplan instance to another server – say from a home server to a rented one – with all accounts, workspaces, files and keys, usually with only a few minutes of downtime.
A Flowplan instance consists of exactly four things:
| Part | What | Moves along |
|---|---|---|
| Container | the image ghcr.io/yannicschuller/flowplan (for linux/amd64 and linux/arm64) | install anew, the same or a newer version |
Data folder /app/data | flowplan.sqlite (+ -wal, -shm), uploads/, web-push-keys.json, secrets.key; ocr/ and tmp/ are recreated | yes – this is the actual move |
| Environment variables | APP_URL, keys, OIDC_*, S3_*, SMTP_* … | copy by hand, above all the keys |
| Domain and reverse proxy | DNS record, HTTPS certificate | switch DNS, set up the proxy again |
This holds for every way of running it: docker run, Docker Compose, Kubernetes with the Helm chart, without Docker or in a deployment panel. If you use a panel, recreate the same container with the same volume and the same variables there.
Prepare
- Write down the environment variables. All the old instance gets – depending on the setup from the
.env, withdocker inspect <container>(sectionEnv),docker compose configorhelm get values flowplan. The full list is under Configuration. - Keep the keys safe, apart from the data (password manager):
FLOWPLAN_ENCRYPTION_KEY(or the file ofFLOWPLAN_ENCRYPTION_KEY_FILE) andFLOWPLAN_ENCRYPTION_KEY_PREVIOUS: without them, encrypted backups, the encrypted files in the bucket and the stored secrets sealed with it cannot be read.FLOWPLAN_SECRETS_KEY(or…_FILE), if set. If neither is set, the key for stored secrets issecrets.keyin the data folder – the built-in move tool takes it along; with the other ways you copy it yourself.- What depends on it: AI keys, calendar connections (Google, Microsoft 365), chat app tokens, Git export tokens and the accounts' two-factor secrets. Without the key, accounts with two-factor sign-in only get in with recovery codes.
- Keep the domain. The same
APP_URLmeans passkeys, the OIDC redirect, CalDAV and CardDAV accounts, the apps, incoming webhooks (Git, tickets, inbound e-mail) and share links simply keep working after the DNS switch. With a new domain all of that must be set up again (see Another domain). - Lower the TTL of the DNS record at least as long beforehand as the current TTL (often 1 to 24 hours), e.g. to 300 seconds. The switch then takes effect within minutes.
- Set up the new server (example: Hetzner Cloud) with the same or a newer Flowplan version – an older one cannot open a newer database – and with the same environment variables. A bucket of its own for the new instance is simplest; with the same bucket see way C.
- Make a backup: Administration → Storage & backup → Whole instance → Download. It never hurts.
- Warn the users: Administration → Maintenance & notices shows everyone a maintenance window.
Three ways
| Way | When | Downtime |
|---|---|---|
| A: Move tool (recommended) | whenever the new server can reach the old one | minutes (only the final sync) |
| B: Download and upload a backup | small instances, no network path between the servers | from the backup until the new instance starts |
| C: S3 and Litestream | the old instance already runs with S3 and Litestream | from stopping the old to starting the new instance |
Way A: The move tool
Under Administration → Storage & backup (admins only), the new instance fetches everything straight from the old one: server to server, resumable and with checksums.
On the old instance – "Prepare the move"
- Move instance → Prepare the move. For safety Flowplan asks for your password; with SSO the sign-in must be at most 15 minutes old (otherwise sign out and in once).
- Flowplan shows the transfer address and the move token (
fpmove_…) – only this once. The token is valid for 24 hours and only for the move: it may read the instance, make it read-only for the final sync and complete the move, nothing else. Only a hash is stored; Revoke invalidates it at once, and a new token replaces the old one. Creating, revoking and every step of the move are in the activity log and a security event under Administration → Security. Ten wrong token attempts from one address lock it out for 15 minutes.
On the new instance – "Take over from another instance"
The new instance is fresh: only the first admin account you create on first start. If it already has content, Flowplan asks for an explicit confirmation, because everything is replaced.
Move instance → Take over from another instance: enter the address of the old instance (its
APP_URL) and the move token, Start the takeover.- If the old server is only in your own network (LAN, a VPN such as WireGuard or Tailscale, e.g.
http://192.168.1.10:3000), setFLOWPLAN_MOVE_ALLOW_PRIVATE=trueon the new instance for the move. Without it, as for every request the server makes, only publichttps://addresses are allowed.
- If the old server is only in your own network (LAN, a VPN such as WireGuard or Tailscale, e.g.
First copy: a consistent database snapshot, every file in
uploads/one by one with a SHA-256 checksum,web-push-keys.jsonandsecrets.key. The page shows bytes, files and the estimated time left. The old instance keeps running as usual. If the connection drops or a server restarts, Resume continues where it stopped – files already checked are not fetched again, a half file continues from its last byte.Pre-flight check → Check. Flowplan compares the settings of both instances – only names and check values (an HMAC keyed with the move token), never the values themselves – and checks whether the new instance reaches S3, SMTP and the OIDC provider. The result is a list of OK, Warning and Error:
Check Meaning Version Error if the new instance is older than the old one: update it first. FLOWPLAN_ENCRYPTION_KEYError if it is missing or different: encrypted backups, the encrypted files in the bucket, stored tokens and calendar connections would be unreadable. Set the old key (a new one only in addition, the old one then in …_PREVIOUS).Stored secrets Error if their key is missing ( FLOWPLAN_SECRETS_KEYorFLOWPLAN_ENCRYPTION_KEY). A generatedsecrets.keycomes along by itself.APP_URLWarning for a different address, with everything that must then be set up again. OIDC, S3, SMTP, push, AI, other Variables missing here, with a different value, or set only here. Missing OIDC_ISSUER/OIDC_CLIENT_IDis an error – nobody would get in with SSO.Same bucket Warning: both instances would write into the same folder (see way C). S3, SMTP, OIDC reachable Error with the service's message. Many providers block outgoing ports 25 and 465 – use port 587 then. Change the new instance's variables and restart it until no errors remain; the move continues after the restart.
Make the source read-only and fetch the last state: downtime starts here. The old instance accepts no more changes – everyone sees "Move in progress: This instance is read-only", reading and signing in still work, changes (also through the API, CalDAV, webhooks, inbound e-mail) are refused with a clear message. The new instance fetches a fresh database snapshot and only the files that are new or changed since the first copy; removed ones go. This usually takes minutes.
Apply on restart → Apply: Flowplan checks the database and prepares everything for the next start – exactly like Restore from backup. The old instance is told: it now shows "This instance has moved to …", stays read-only, and its move token is used up.
Restart the new instance (the container, the pod or the service). On start Flowplan swaps in database, files and keys; the new instance's previous state stays in the data folder as
pre-move-<time>. The log saysInstanz wiederhergestellt; vorheriger Stand in …/pre-move-….Switch DNS (below) and check afterwards.
You now sign in on the new instance with the accounts of the old one; the new one's first admin account is replaced. Existing sign-ins (browsers, apps) stay valid as long as the domain stays the same.
On the old instance, Move instance also offers Redirect the web interface: whoever opens it in the browser goes straight to the new address. Lift read-only makes it writable again – only when the move is called off.
Without the interface (command line)
The same without a web interface on the new instance – before any account exists there, say, or over SSH while DNS still points to the old server. The tool is in the image as /app/move.mjs and in the source as scripts/move.mjs (npm run move). It fetches the first copy, makes the old instance read-only, fetches the last state, prepares everything for the next start and tells the old instance the new address (--target-url, otherwise APP_URL). Interrupted? Run the same command again – it continues where it stopped.
# Docker Compose (in the new instance's folder; same .env, same volume)
docker compose run --rm flowplan node /app/move.mjs \
--from https://flowplan.example.org --token fpmove_…
docker compose up -d # starts and applies the copy
# docker run
docker run --rm -v flowplan-data:/app/data -e APP_URL=https://flowplan.example.org \
ghcr.io/yannicschuller/flowplan node /app/move.mjs --from https://flowplan.example.org --token fpmove_…
# Kubernetes (Helm chart)
kubectl -n flowplan exec deploy/flowplan -- node /app/move.mjs --from https://flowplan.example.org --token fpmove_…
kubectl -n flowplan rollout restart deploy/flowplan
# without Docker
npm run move -- --from https://flowplan.example.org --token fpmove_… --data /var/lib/flowplan| Option | Meaning |
|---|---|
--from <address> | Address of the old instance. On the command line internal addresses and http:// are allowed too – you decide where it connects. |
--token <token> | The move token (or FLOWPLAN_MOVE_TOKEN in the environment, to keep it out of the shell history). |
--data <folder> | Data folder, default FLOWPLAN_DATA_DIR or ./data. |
--target-url <address> | Address of the new instance for the notice on the old one (default APP_URL). |
--copy-only | Only the first copy; the old instance stays writable. Later run it again without the option for the final sync. |
--yes | Replace a data folder that already has content (its previous state stays as pre-move-…). |
The pre-flight check is only in the interface; on the command line compare the variables yourself (list above).
Way B: Download and upload a backup
- On the old instance announce maintenance – changes after the download are lost.
- Administration → Storage & backup → Whole instance → Download. With
FLOWPLAN_ENCRYPTION_KEYthe file is encrypted (.zip.enc) – the new instance then needs the same key. - The instance backup holds the database and files but not
secrets.keyandweb-push-keys.json. Copy both by hand (or set the keys as variables):# old docker compose cp flowplan:/app/data/secrets.key . docker compose cp flowplan:/app/data/web-push-keys.json . # new docker compose cp secrets.key flowplan:/app/data/ docker compose cp web-push-keys.json flowplan:/app/data/ docker compose exec -u root flowplan chown node:node /app/data/secrets.key /app/data/web-push-keys.json - On the new instance Restore from backup → Choose backup. The proxy in front must let such large uploads through (Caddy does by itself, nginx with
client_max_body_size, Cloudflare at most 100 MB); otherwise bring the file to the server withscpand upload it there. At most 50 GB. - Restart; the previous state stays as
pre-restore-…. Then switch DNS.
Way C: S3 and Litestream
If the old instance runs with S3 (files in the bucket) and Litestream (a continuous copy of the database under <prefix>/db/), the new instance can start straight from the bucket:
- Stop the old instance – the Litestream of two instances must never write into the same folder at the same time.
- Start the new instance with the same
S3_*variables, the sameFLOWPLAN_ENCRYPTION_KEYand an empty data folder. Litestream restores the database; Flowplan fetches files on first access right away and all others in the background. - Copy
secrets.keyandweb-push-keys.jsonas in way B unless the keys are set as variables. - Switch DNS.
With FLOWPLAN_LITESTREAM=off there is no continuous copy: use the newest scheduled backup from <prefix>/backups/ instead (see Restore). The pre-flight check of way A notices a Litestream copy in the bucket and points to this way.
Switch DNS
- After the final sync (way A) or the start of the new instance, change the domain's A record (IPv4) and AAAA record (IPv6) to the new server. Remove a port forwarding or tunnel at the old location.
- The new server's proxy gets its certificate as soon as the domain points to it (with Let's Encrypt over port 80) – give it a minute.
- Check what the world sees with
dig +short flowplan.example.org(ornslookup). Whoever still has the old address cached reaches the old instance until the TTL runs out – which then shows the notice with the new one. - Raise the TTL again afterwards.
Check afterwards
- Sign in: with password, passkey and SSO. Existing sessions stay valid.
- Administration → Monitoring: all green? Administration → Encryption shows the same key ID as before.
- Administration → Storage & backup: S3 connected, the queue drains (with S3, Flowplan uploads every file once after a takeover), Back up now succeeds.
- Administration → E-mail → Send test email and Administration → Push notifications → Send test notification.
- Open a few pages, images and attachments; upload a file.
- AI, calendar connections, chat apps: if they work without signing in again, the stored secrets are readable.
- Refresh CalDAV/CardDAV in an app, open the desktop and mobile apps, trigger incoming webhooks (Git, tickets) once.
Clean up
- Remove
FLOWPLAN_MOVE_ALLOW_PRIVATEon the new instance. - Delete
pre-move-…(orpre-restore-…) in the data folder after a few days, e.g.docker compose exec flowplan sh -c 'rm -rf /app/data/pre-move-*'. - Keep the old instance running with its notice for a few days, then stop it, wipe its volume and revoke its S3 credentials (if it had its own). The move token is already used up.
- Turn on or check a scheduled backup on the new instance (Backups).
Going back if something goes wrong
Until the DNS switch nothing is lost: on the old instance Administration → Storage & backup → Move instance → Lift read-only, and everything runs as before. After the switch: switch DNS back and lift the read-only state – what was changed on the new instance in the meantime only comes back with a move in the other direction. The new instance itself returns to its previous state when you replace flowplan.sqlite* and uploads/ with the contents of pre-move-… while the container is stopped.
Another domain
If the instance gets a new address, set APP_URL to it and then set up again:
- Passkeys are bound to the domain: everyone signs in with password or SSO and creates them again.
- OIDC: change the redirect address at the provider to
<new APP_URL>/api/auth/callback. - CalDAV and CardDAV: set up the accounts in the calendar and contacts apps again.
- Incoming webhooks (Git, ticket sync, inbound e-mail) and share links point to the old address.
- Desktop and mobile apps store the server's address. They keep connecting to the old instance; there everyone sees the notice "This instance has moved to …", and changes are refused with a message naming the new address. Sign out in the app and enter the new address (desktop: Profiles → Add profile …). Leave Redirect the web interface off meanwhile: the apps refuse an address that redirects to another site.
Troubleshooting
| Message or state | Cause and fix |
|---|---|
| "Internal addresses are only allowed with FLOWPLAN_MOVE_ALLOW_PRIVATE=true" | The old instance has a private address (LAN, VPN, localhost) or only http://. Set FLOWPLAN_MOVE_ALLOW_PRIVATE=true on the new instance, restart, remove it after the move. |
| "Invalid or expired move token" | Copied wrongly, older than 24 hours, revoked, replaced by a new one or used up by a completed move. Create a new one on the old instance. |
| "Too many invalid move requests" | Ten wrong tokens from one address: wait 15 minutes. |
| The transfer stops | Resume (or run the command again). Finished files stay, half ones continue. |
| "Checksum does not match" | The file arrived damaged and is fetched again automatically; if it keeps happening, check the connection (proxy, VPN). |
| "This database snapshot no longer exists" | The old instance made a new snapshot in the meantime (new token, restart). Resume fetches it. |
| Time-out at the start (502/504) | The first time, the old instance computes the checksums of all files. A proxy with a short time limit in front (nginx: proxy_read_timeout 60 s, Cloudflare 100 s) gives up. Raise the limit or use the address without the proxy (with FLOWPLAN_MOVE_ALLOW_PRIVATE). |
Pre-flight: error for FLOWPLAN_ENCRYPTION_KEY or stored secrets | Set the old instance's key on the new one and restart. Only then switch. |
| SMTP not reachable | Many providers block outgoing ports 25 and 465. Use SMTP_PORT=587 with STARTTLS. |
| Nothing happens after Apply | Applying needs a restart of the new instance. The log must say Instanz wiederhergestellt. |
| The old instance says "read-only" although the move was called off | Move instance → Lift read-only. |
Example: Hetzner Cloud
A concrete way from a home server to a server at Hetzner, with Docker Compose and Caddy. Plan names and prices change – the Hetzner Console is what counts.
Server
| Setting | Recommendation |
|---|---|
| Type | CAX21 (Arm64, 4 vCPU, 8 GB RAM) or CX32 (x86, 4 vCPU, 8 GB RAM), or their current successor. 4 GB is enough for small teams; text recognition of scanned PDFs needs CPU for a moment, local AI (Ollama) much more. The image exists for linux/arm64 and linux/amd64, both types fit. |
| Location | Falkenstein (fsn1) or Nuremberg (nbg1) – Object Storage is available there too. |
| Image | Ubuntu 24.04 |
| Networking | Public IPv4 and IPv6. Keep an IPv4 address: not every registry and mail server is reachable over IPv6. |
| SSH key | Add your own public key; never use password sign-in. |
| Firewall | Create and assign a Hetzner Cloud Firewall: inbound TCP 22 only from your address (or your VPN), TCP 80 and 443 and UDP 443 (HTTP/3) from anywhere. Docker bypasses ufw on the server; the Cloud Firewall in front still applies. |
| Backups | Turn on the server's Backups (a surcharge on the server price): daily images of the whole disk, in addition to Flowplan's own backups. |
Object Storage as S3
- In the Hetzner Console Object Storage → Create bucket, same location as the server (e.g. Falkenstein), visibility private. The name must be unique, e.g.
flowplan-<your-name>. - Security → S3 credentials → Generate credentials. The secret is shown only once.
- Into the
.env:
S3_BUCKET=flowplan-example
S3_ENDPOINT=https://fsn1.your-objectstorage.com
S3_REGION=fsn1
S3_ACCESS_KEY_ID=<access key>
S3_SECRET_ACCESS_KEY=<secret key>
S3_PREFIX=flowplanEndpoint and region follow the location: https://<location>.your-objectstorage.com with fsn1 (Falkenstein), nbg1 (Nuremberg) or hel1 (Helsinki) as S3_REGION. With FLOWPLAN_ENCRYPTION_KEY, files and backups are stored there encrypted; the Litestream copy is not (see Litestream and encryption). Object Storage is billed monthly, with a base price that includes a quota – see Hetzner for current prices.
Docker, Flowplan and Caddy
Install Docker following the official guide for Ubuntu (docs.docker.com/engine/install/ubuntu (opens in a new tab)), then:
mkdir -p /opt/flowplan && cd /opt/flowplan
curl -o .env https://raw.githubusercontent.com/yannicschuller/flowplan/main/.env.example
# create compose.yaml and Caddyfile from below, fill in .env
docker compose up -dcompose.yaml:
# Flowplan with Caddy in front (HTTPS from Let's Encrypt). Exactly one instance (SQLite).
services:
flowplan:
image: ghcr.io/yannicschuller/flowplan:${FLOWPLAN_VERSION:-latest}
env_file: .env
environment:
FLOWPLAN_DATA_DIR: /app/data
# Caddy is the one proxy in front and sets X-Forwarded-For.
FLOWPLAN_TRUSTED_PROXIES: "1"
volumes:
- flowplan-data:/app/data
restart: unless-stopped
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
interval: 30s
timeout: 5s
start_period: 20s
retries: 3
caddy:
image: caddy:2
ports:
- "80:80"
- "443:443"
- "443:443/udp"
environment:
FLOWPLAN_DOMAIN: ${FLOWPLAN_DOMAIN}
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy-data:/data
- caddy-config:/config
depends_on:
- flowplan
restart: unless-stopped
volumes:
flowplan-data:
caddy-data:
caddy-config:Caddyfile:
{$FLOWPLAN_DOMAIN} {
encode zstd gzip
reverse_proxy flowplan:3000
}In the .env, besides the old instance's values:
FLOWPLAN_DOMAIN=flowplan.example.org
APP_URL=https://flowplan.example.org
# SMTP: Hetzner blocks outgoing ports 25 and 465 for new accounts – use 587 with STARTTLS.
SMTP_PORT=587
SMTP_SECURE=false
# Only for the move, when the home server is reachable only in the LAN/VPN:
# FLOWPLAN_MOVE_ALLOW_PRIVATE=trueFor a temporary address during the move, set FLOWPLAN_DOMAIN=new.flowplan.example.org and the matching APP_URL; after Apply, change both to the final domain and restart with docker compose up -d – the same restart applies the move. Caddy can also serve both names: {$FLOWPLAN_DOMAIN}, new.flowplan.example.org { … }.
Prefer Kubernetes? The repository's Helm chart runs on a single server too (with k3s, say); the move then goes through the interface or kubectl exec … node /app/move.mjs (above).
The run with the home server
- A day before, lower the DNS record's TTL to 300 seconds.
- Set up the Hetzner server as above, with the same variables as at home (the keys!), and start it with
docker compose up -d; create the first account. - At home Prepare the move; on the Hetzner server Take over from another instance with the home server's public address (or its VPN address with
FLOWPLAN_MOVE_ALLOW_PRIVATE=true). - Pre-flight check until no errors remain. Make the source read-only and fetch the last state, Apply,
docker compose restart flowplan. - Point the A and AAAA records to the Hetzner server's addresses, remove the port forwarding at the router.
- Check afterwards, clean up after a few days.