Skip to content
flowplanDocumentationCommunityStart page

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:

PartWhatMoves along
Containerthe image ghcr.io/yannicschuller/flowplan (for linux/amd64 and linux/arm64)install anew, the same or a newer version
Data folder /app/dataflowplan.sqlite (+ -wal, -shm), uploads/, web-push-keys.json, secrets.key; ocr/ and tmp/ are recreatedyes – this is the actual move
Environment variablesAPP_URL, keys, OIDC_*, S3_*, SMTP_* …copy by hand, above all the keys
Domain and reverse proxyDNS record, HTTPS certificateswitch 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

  1. Write down the environment variables. All the old instance gets – depending on the setup from the .env, with docker inspect <container> (section Env), docker compose config or helm get values flowplan. The full list is under Configuration.
  2. Keep the keys safe, apart from the data (password manager):
    • FLOWPLAN_ENCRYPTION_KEY (or the file of FLOWPLAN_ENCRYPTION_KEY_FILE) and FLOWPLAN_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 is secrets.key in 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.
  3. Keep the domain. The same APP_URL means 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).
  4. 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.
  5. 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.
  6. Make a backup: Administration → Storage & backup → Whole instance → Download. It never hurts.
  7. Warn the users: Administration → Maintenance & notices shows everyone a maintenance window.

Three ways

WayWhenDowntime
A: Move tool (recommended)whenever the new server can reach the old oneminutes (only the final sync)
B: Download and upload a backupsmall instances, no network path between the serversfrom the backup until the new instance starts
C: S3 and Litestreamthe old instance already runs with S3 and Litestreamfrom 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"

  1. 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).
  2. 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.

  1. 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), set FLOWPLAN_MOVE_ALLOW_PRIVATE=true on the new instance for the move. Without it, as for every request the server makes, only public https:// addresses are allowed.
  2. First copy: a consistent database snapshot, every file in uploads/ one by one with a SHA-256 checksum, web-push-keys.json and secrets.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.

  3. 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:

    CheckMeaning
    VersionError 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 secretsError if their key is missing (FLOWPLAN_SECRETS_KEY or FLOWPLAN_ENCRYPTION_KEY). A generated secrets.key comes along by itself.
    APP_URLWarning for a different address, with everything that must then be set up again.
    OIDC, S3, SMTP, push, AI, otherVariables missing here, with a different value, or set only here. Missing OIDC_ISSUER/OIDC_CLIENT_ID is an error – nobody would get in with SSO.
    Same bucketWarning: both instances would write into the same folder (see way C).
    S3, SMTP, OIDC reachableError 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.

  4. 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.

  5. 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.

  6. 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 says Instanz wiederhergestellt; vorheriger Stand in …/pre-move-….

  7. 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
OptionMeaning
--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-onlyOnly the first copy; the old instance stays writable. Later run it again without the option for the final sync.
--yesReplace 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

  1. On the old instance announce maintenance – changes after the download are lost.
  2. Administration → Storage & backup → Whole instance → Download. With FLOWPLAN_ENCRYPTION_KEY the file is encrypted (.zip.enc) – the new instance then needs the same key.
  3. The instance backup holds the database and files but not secrets.key and web-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
  4. 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 with scp and upload it there. At most 50 GB.
  5. 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:

  1. Stop the old instance – the Litestream of two instances must never write into the same folder at the same time.
  2. Start the new instance with the same S3_* variables, the same FLOWPLAN_ENCRYPTION_KEY and an empty data folder. Litestream restores the database; Flowplan fetches files on first access right away and all others in the background.
  3. Copy secrets.key and web-push-keys.json as in way B unless the keys are set as variables.
  4. 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

  1. 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.
  2. 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.
  3. Check what the world sees with dig +short flowplan.example.org (or nslookup). 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.
  4. 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_PRIVATE on the new instance.
  • Delete pre-move-… (or pre-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 stateCause 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 stopsResume (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 secretsSet the old instance's key on the new one and restart. Only then switch.
SMTP not reachableMany providers block outgoing ports 25 and 465. Use SMTP_PORT=587 with STARTTLS.
Nothing happens after ApplyApplying needs a restart of the new instance. The log must say Instanz wiederhergestellt.
The old instance says "read-only" although the move was called offMove 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

SettingRecommendation
TypeCAX21 (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.
LocationFalkenstein (fsn1) or Nuremberg (nbg1) – Object Storage is available there too.
ImageUbuntu 24.04
NetworkingPublic IPv4 and IPv6. Keep an IPv4 address: not every registry and mail server is reachable over IPv6.
SSH keyAdd your own public key; never use password sign-in.
FirewallCreate 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.
BackupsTurn 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

  1. 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>.
  2. Security → S3 credentials → Generate credentials. The secret is shown only once.
  3. 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=flowplan

Endpoint 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 -d

compose.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=true

For 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

  1. A day before, lower the DNS record's TTL to 300 seconds.
  2. 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.
  3. 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).
  4. Pre-flight check until no errors remain. Make the source read-only and fetch the last state, Apply, docker compose restart flowplan.
  5. Point the A and AAAA records to the Hetzner server's addresses, remove the port forwarding at the router.
  6. Check afterwards, clean up after a few days.

Edit on GitHub