Now booking enterprise content platform builds for 2026. Contact us

All articles Engineering 11 min read

Coolify migrating resources between teams

A database-level guide to moving resources between teams in Coolify — connecting to the production Postgres over an SSH tunnel, the team → project → environment relation model, and tested SQL templates for migrating a whole team or a single project, with a full backup and rollback path.


🚨 Before executing any scripts, make sure to create a full database dump first.

Run a backup of the Coolify PostgreSQL database before proceeding with any migration or modification scripts. In case of an error, this backup is your only way to restore the previous state.

This guide covers how to connect to the Coolify PostgreSQL database, the data model behind teams and resources, and procedures for migrating projects and servers between teams — either an entire team or a single project. It includes SQL templates and worked examples.

0. Pre-flight: database backup

Before running any migration scripts, create a full dump of the Coolify database. The SSH tunnel from section 1 must be running on port 5433.

0.1 Create a dump

Run from your local machine (requires the SSH tunnel from section 1.3 to be active):

pg_dump \
  --host=localhost \
  --port=5433 \
  --username=<DB_USER> \
  --dbname=coolify \
  --schema=public \
  --no-owner \
  --no-privileges \
  --format=custom \
  --verbose \
  --file=coolify_backup_$(date +%Y-%m-%d).dump

💡 Replace <DB_USER> with the value obtained in step 1.4. You’ll be prompted for the password.

0.2 Copy the dump to / from the server

Copy the file from the server to your local machine:

scp -i ~/.ssh/<your-key> [email protected]:./coolify_backup.dump ./

Copy a file from your local machine to the server:

scp -i ~/.ssh/<your-key> ./coolify_backup.dump [email protected]:

0.3 Restore the dump to a local Docker database

Run from your local machine:

docker run --rm \
  --network <your-local-network> \
  -e PGPASSWORD=<local-db-password> \
  -v "$PWD":/backup \
  postgres:17 \
  bash -c "pg_restore \
    -h <local-postgres-container-name> \
    -U postgres \
    -d coolify \
    --clean \
    --if-exists \
    --no-owner \
    --no-privileges \
    --verbose \
    /backup/coolify_backup.dump 2>&1 | grep -v 'transaction_timeout' || true"

💡 If you see an error about a missing ROLE (e.g. master), create it first in your local database: CREATE ROLE master;

1. Connecting to the Coolify database (production)

The Coolify PostgreSQL database runs in a Docker container on the server and is not exposed to the internet. Access is via an SSH tunnel.

1.1 Requirements

  • An SSH key with access to the server (e.g. ~/.ssh/id_rsa or your preferred key).
  • SSH access to the server as root.

1.2 Step 1: database container IP on the server

ssh [email protected] "docker ps | grep -i postgres"

Option A — a Docker Go template (make sure the {{ }} brackets aren’t lost when you copy it):

docker inspect coolify-db --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}'

Note the container IP (e.g. xxx.xx.x.x). The database is not mapped to a host port, so the tunnel must target the container IP.

1.3 Step 2: SSH tunnel

Example (container IP xxx.xx.x.x):

ssh -N -L 5433:xxx.xx.x.x:5432 [email protected]

Keep the terminal open.

1.4 Step 3: database credentials

ssh [email protected] "cat /data/coolify/source/.env | grep -i -E 'DB_|POSTGRES'"

Alternatively (via Docker):

ssh [email protected] "docker inspect coolify-db | grep -i -E 'POSTGRES_PASSWORD|POSTGRES_USER|POSTGRES_DB'"

1.5 Step 4: connecting in a database client (VS Code / Database Client)

Create a new connection without using the client’s built-in SSH (the tunnel is already running in the terminal):

ParameterValue
Hostlocalhost
Port5433
User(from step 3)
Password(from step 3)
Databasecoolify

2. Database relation structure

Resources are linked to a team either directly (team_id) or through the hierarchy project → environment.

team
 ├── projects
 │    └── environments
 │         ├── applications
 │         │    ├── application_settings
 │         │    ├── application_previews
 │         │    └── application_deployment_queues
 │         ├── services
 │         │    ├── service_applications
 │         │    └── service_databases
 │         └── standalone_* (postgres, redis, mysql, mariadb, mongo, clickhouse, dragonfly, keydb)
 ├── servers
 │    └── server_settings
 ├── private_keys
 ├── github_apps / gitlab_apps
 ├── s3_storages
 ├── tags
 ├── scheduled_database_backups
 ├── scheduled_tasks
 ├── shared_environment_variables
 ├── notification settings (discord, email, slack, telegram, pushover)
 ├── subscriptions
 └── personal_access_tokens

The applications, services, and standalone_* tables do not have a team_id column — team membership is derived from environment_id → environments.project_id → projects.team_id.

Moving a project means updating projects.team_id and any related resources that carry their own team_id (servers, keys, backups, and so on).

3. Tables with a direct team_id

TableNotes
projectsMain table — moving it moves environments and everything under them
serversServers hosting resources
private_keysSSH keys
github_appsGitHub integrations
gitlab_appsGitLab integrations
s3_storagesBackup storage
tagsResource tags
scheduled_database_backupsScheduled backups
scheduled_tasksCron tasks
shared_environment_variablesShared env variables
discord_notification_settingsDiscord notifications
email_notification_settingsEmail notifications
slack_notification_settingsSlack notifications
telegram_notification_settingsTelegram notifications
pushover_notification_settingsPushover notifications
subscriptionsStripe subscriptions
team_userTeam ↔ user pivot (optional)
personal_access_tokensAPI tokens (team_id is varchar)

4. Migration scenarios

4.1 Migrating an entire team A → B

All team resources are moved (projects, servers, keys, integrations, backups, notifications, and so on). Use the template in 5.1.

4.2 Migrating only resources tied to a single project

Only the following are moved:

  • The chosen project (projects.id = X);
  • Servers that are the destination for applications, services, or standalone DBs in that project’s environments (with optional exclusions, e.g. localhost);
  • Private keys, GitHub/GitLab apps, S3, tags, scheduled backups/tasks, and shared env vars — only those linked to that project.

Notification settings, subscriptions, and personal access tokens are not moved; they stay at team level. Templates: dry-run 5.2, migration 5.3.

4.3 Reverting a single server to its previous team

For example, the “localhost” server (id=0) should remain in team 0 despite the project migration. Template: 6.1.

5. Scripts and templates

5.1 Template: migrate an entire team A → B

Replace old_team and new_team with the correct IDs. Back up the database before running, and run the state-check query (5.4) first.

BEGIN;

DO $$
DECLARE
  old_team bigint := 0;   -- <-- CHANGE: source team ID
  new_team bigint := 1;   -- <-- CHANGE: target team ID
  env_ids  bigint[];
  proj_ids bigint[];
BEGIN
  SELECT array_agg(id) INTO proj_ids FROM projects WHERE team_id = old_team;
  UPDATE projects SET team_id = new_team WHERE team_id = old_team;

  UPDATE servers SET team_id = new_team WHERE team_id = old_team;

  SELECT array_agg(id) INTO env_ids
  FROM environments WHERE project_id = ANY(proj_ids);

  UPDATE private_keys SET team_id = new_team WHERE team_id = old_team;
  UPDATE github_apps SET team_id = new_team WHERE team_id = old_team;
  UPDATE gitlab_apps SET team_id = new_team WHERE team_id = old_team;
  UPDATE s3_storages SET team_id = new_team WHERE team_id = old_team;
  UPDATE tags SET team_id = new_team WHERE team_id = old_team;

  UPDATE scheduled_database_backups SET team_id = new_team WHERE team_id = old_team;
  UPDATE scheduled_tasks SET team_id = new_team WHERE team_id = old_team;

  UPDATE shared_environment_variables SET team_id = new_team WHERE team_id = old_team;

  UPDATE discord_notification_settings SET team_id = new_team WHERE team_id = old_team;
  UPDATE email_notification_settings SET team_id = new_team WHERE team_id = old_team;
  UPDATE slack_notification_settings SET team_id = new_team WHERE team_id = old_team;
  UPDATE telegram_notification_settings SET team_id = new_team WHERE team_id = old_team;
  UPDATE pushover_notification_settings SET team_id = new_team WHERE team_id = old_team;

  UPDATE subscriptions SET team_id = new_team WHERE team_id = old_team;

  UPDATE personal_access_tokens SET team_id = new_team::text WHERE team_id = old_team::text;

  -- Optional: move team members
  -- UPDATE team_user SET team_id = new_team WHERE team_id = old_team;

  RAISE NOTICE 'Moved resources from team % to team %', old_team, new_team;
END $$;

-- Verify before COMMIT:
-- SELECT id, name, team_id FROM projects;
-- SELECT id, name, team_id FROM servers;

COMMIT;
-- On failure: ROLLBACK;

5.2 Template: dry-run — migrate a single project

Read-only queries that show what would be moved for one project to the chosen team. Replace 10 (the project ID) and 11 (the new team) with your values.

Team state:

SELECT 'projects' AS table_name, team_id, count(*) FROM projects GROUP BY team_id
UNION ALL
SELECT 'servers', team_id, count(*) FROM servers GROUP BY team_id
UNION ALL
SELECT 'private_keys', team_id, count(*) FROM private_keys GROUP BY team_id
UNION ALL
SELECT 'github_apps', team_id, count(*) FROM github_apps GROUP BY team_id
UNION ALL
SELECT 's3_storages', team_id, count(*) FROM s3_storages GROUP BY team_id
UNION ALL
SELECT 'tags', team_id, count(*) FROM tags GROUP BY team_id
ORDER BY table_name, team_id;

Verify the project and target team:

SELECT id, name, team_id FROM projects WHERE id = 10;
SELECT id, name FROM teams WHERE id = 11;

Servers linked to the project (example for project_id = 10):

WITH envs AS (SELECT id FROM environments WHERE project_id = 10),
dest_servers AS (
  SELECT sd.server_id FROM applications a
  JOIN envs e ON e.id = a.environment_id
  JOIN standalone_dockers sd ON a.destination_type LIKE '%StandaloneDocker' AND a.destination_id = sd.id
  UNION
  SELECT sw.server_id FROM applications a JOIN envs e ON e.id = a.environment_id
  JOIN swarm_dockers sw ON a.destination_type LIKE '%SwarmDocker' AND a.destination_id = sw.id
  UNION
  SELECT sd.server_id FROM services srv JOIN envs e ON e.id = srv.environment_id
  JOIN standalone_dockers sd ON srv.destination_type LIKE '%StandaloneDocker' AND srv.destination_id = sd.id
  UNION
  SELECT sw.server_id FROM services srv JOIN envs e ON e.id = srv.environment_id
  JOIN swarm_dockers sw ON srv.destination_type LIKE '%SwarmDocker' AND srv.destination_id = sw.id
  UNION
  SELECT sd.server_id FROM standalone_postgresqls sp JOIN envs e ON e.id = sp.environment_id
  JOIN standalone_dockers sd ON sp.destination_type LIKE '%StandaloneDocker' AND sp.destination_id = sd.id
  UNION
  SELECT sw.server_id FROM standalone_postgresqls sp JOIN envs e ON e.id = sp.environment_id
  JOIN swarm_dockers sw ON sp.destination_type LIKE '%SwarmDocker' AND sp.destination_id = sw.id
)
SELECT s.id, s.name, s.team_id FROM dest_servers ds
JOIN servers s ON s.id = ds.server_id
ORDER BY s.id;

Use the dry-run to decide which servers to exclude (e.g. localhost) before running the actual migration.

5.3 Template: migrate a single project (actual migration)

Moves the chosen project, only the related servers (with optional exclusion of e.g. server_id = 0), private keys, GitHub/GitLab apps, S3, tags, scheduled backups/tasks, and shared env vars tied to that project.

Parameters to set at the start of the block:

  • project_id_to_move — ID of the project to move
  • old_team — current team (e.g. 0)
  • new_team — target team (e.g. 11)
BEGIN;

DO $$
DECLARE
  project_id_to_move bigint := 10;   -- <-- CHANGE
  old_team bigint := 0;              -- <-- CHANGE
  new_team bigint := 11;             -- <-- CHANGE
  env_ids bigint[];
  server_ids bigint[];
  key_ids bigint[];
  github_ids bigint[];
  gitlab_ids bigint[];
  s3_ids bigint[];
  tag_ids bigint[];
BEGIN
  SELECT array_agg(id) INTO env_ids FROM environments WHERE project_id = project_id_to_move;

  UPDATE projects SET team_id = new_team WHERE id = project_id_to_move AND team_id = old_team;

  WITH dest_servers AS (
    SELECT sd.server_id FROM applications a
    JOIN environments e ON e.id = a.environment_id AND e.project_id = project_id_to_move
    JOIN standalone_dockers sd ON a.destination_type LIKE '%StandaloneDocker' AND a.destination_id = sd.id
    UNION SELECT sw.server_id FROM applications a
    JOIN environments e ON e.id = a.environment_id AND e.project_id = project_id_to_move
    JOIN swarm_dockers sw ON a.destination_type LIKE '%SwarmDocker' AND a.destination_id = sw.id
    UNION SELECT sd.server_id FROM services srv
    JOIN environments e ON e.id = srv.environment_id AND e.project_id = project_id_to_move
    JOIN standalone_dockers sd ON srv.destination_type LIKE '%StandaloneDocker' AND srv.destination_id = sd.id
    UNION SELECT sw.server_id FROM services srv
    JOIN environments e ON e.id = srv.environment_id AND e.project_id = project_id_to_move
    JOIN swarm_dockers sw ON srv.destination_type LIKE '%SwarmDocker' AND srv.destination_id = sw.id
    UNION SELECT sd.server_id FROM standalone_postgresqls sp
    JOIN environments e ON e.id = sp.environment_id AND e.project_id = project_id_to_move
    JOIN standalone_dockers sd ON sp.destination_type LIKE '%StandaloneDocker' AND sp.destination_id = sd.id
    UNION SELECT sw.server_id FROM standalone_postgresqls sp
    JOIN environments e ON e.id = sp.environment_id AND e.project_id = project_id_to_move
    JOIN swarm_dockers sw ON sp.destination_type LIKE '%SwarmDocker' AND sp.destination_id = sw.id
  )
  SELECT array_agg(DISTINCT server_id) INTO server_ids FROM dest_servers;

  -- Exclude localhost (id=0) or other servers — edit as needed
  IF server_ids IS NOT NULL AND array_length(server_ids, 1) > 0 THEN
    server_ids := array_remove(server_ids, 0::bigint);
  END IF;
  IF server_ids IS NOT NULL AND array_length(server_ids, 1) > 0 THEN
    UPDATE servers SET team_id = new_team WHERE id = ANY(server_ids) AND team_id = old_team;
  END IF;

  SELECT array_agg(DISTINCT private_key_id) INTO key_ids
  FROM applications WHERE environment_id = ANY(env_ids) AND private_key_id IS NOT NULL;
  IF key_ids IS NOT NULL AND array_length(key_ids, 1) > 0 THEN
    UPDATE private_keys SET team_id = new_team WHERE id = ANY(key_ids) AND team_id = old_team;
  END IF;

  SELECT array_agg(DISTINCT a.source_id) INTO github_ids
  FROM applications a WHERE a.environment_id = ANY(env_ids) AND a.source_type IS NOT NULL AND a.source_type LIKE '%GitHub%';
  IF github_ids IS NOT NULL AND array_length(github_ids, 1) > 0 THEN
    UPDATE github_apps SET team_id = new_team WHERE id = ANY(github_ids) AND team_id = old_team;
  END IF;

  SELECT array_agg(DISTINCT a.source_id) INTO gitlab_ids
  FROM applications a WHERE a.environment_id = ANY(env_ids) AND a.source_type IS NOT NULL AND a.source_type LIKE '%GitLab%';
  IF gitlab_ids IS NOT NULL AND array_length(gitlab_ids, 1) > 0 THEN
    UPDATE gitlab_apps SET team_id = new_team WHERE id = ANY(gitlab_ids) AND team_id = old_team;
  END IF;

  WITH backup_s3 AS (
    SELECT sdb.s3_storage_id FROM scheduled_database_backups sdb
    JOIN standalone_postgresqls sp ON sdb.database_type LIKE '%StandalonePostgresql' AND sdb.database_id = sp.id AND sp.environment_id = ANY(env_ids) WHERE sdb.s3_storage_id IS NOT NULL
    UNION SELECT sdb.s3_storage_id FROM scheduled_database_backups sdb
    JOIN standalone_redis sr ON sdb.database_type LIKE '%StandaloneRedis' AND sdb.database_id = sr.id AND sr.environment_id = ANY(env_ids) WHERE sdb.s3_storage_id IS NOT NULL
    UNION SELECT sdb.s3_storage_id FROM scheduled_database_backups sdb
    JOIN standalone_mongodbs sm ON sdb.database_type LIKE '%StandaloneMongodb' AND sdb.database_id = sm.id AND sm.environment_id = ANY(env_ids) WHERE sdb.s3_storage_id IS NOT NULL
    UNION SELECT sdb.s3_storage_id FROM scheduled_database_backups sdb
    JOIN standalone_mysqls sm ON sdb.database_type LIKE '%StandaloneMysql' AND sdb.database_id = sm.id AND sm.environment_id = ANY(env_ids) WHERE sdb.s3_storage_id IS NOT NULL
    UNION SELECT sdb.s3_storage_id FROM scheduled_database_backups sdb
    JOIN standalone_mariadbs sm ON sdb.database_type LIKE '%StandaloneMariadb' AND sdb.database_id = sm.id AND sm.environment_id = ANY(env_ids) WHERE sdb.s3_storage_id IS NOT NULL
  )
  SELECT array_agg(DISTINCT s3_storage_id) INTO s3_ids FROM backup_s3 WHERE s3_storage_id IS NOT NULL;
  IF s3_ids IS NOT NULL AND array_length(s3_ids, 1) > 0 THEN
    UPDATE s3_storages SET team_id = new_team WHERE id = ANY(s3_ids) AND team_id = old_team;
  END IF;

  SELECT array_agg(DISTINCT t.tag_id) INTO tag_ids FROM taggables t
  WHERE (t.taggable_type LIKE '%Application' AND t.taggable_id IN (SELECT id FROM applications WHERE environment_id = ANY(env_ids)))
     OR (t.taggable_type LIKE '%Service' AND t.taggable_id IN (SELECT id FROM services WHERE environment_id = ANY(env_ids)))
     OR (t.taggable_type LIKE '%StandalonePostgresql' AND t.taggable_id IN (SELECT id FROM standalone_postgresqls WHERE environment_id = ANY(env_ids)))
     OR (t.taggable_type LIKE '%StandaloneRedis' AND t.taggable_id IN (SELECT id FROM standalone_redis WHERE environment_id = ANY(env_ids)))
     OR (t.taggable_type LIKE '%StandaloneMongodb' AND t.taggable_id IN (SELECT id FROM standalone_mongodbs WHERE environment_id = ANY(env_ids)))
     OR (t.taggable_type LIKE '%StandaloneMysql' AND t.taggable_id IN (SELECT id FROM standalone_mysqls WHERE environment_id = ANY(env_ids)))
     OR (t.taggable_type LIKE '%StandaloneMariadb' AND t.taggable_id IN (SELECT id FROM standalone_mariadbs WHERE environment_id = ANY(env_ids)));
  IF tag_ids IS NOT NULL AND array_length(tag_ids, 1) > 0 THEN
    UPDATE tags SET team_id = new_team WHERE id = ANY(tag_ids) AND (team_id = old_team OR team_id IS NULL);
  END IF;

  UPDATE scheduled_database_backups sdb SET team_id = new_team
  WHERE sdb.team_id = old_team
    AND (
      (sdb.database_type LIKE '%StandalonePostgresql' AND sdb.database_id IN (SELECT id FROM standalone_postgresqls WHERE environment_id = ANY(env_ids)))
      OR (sdb.database_type LIKE '%StandaloneRedis' AND sdb.database_id IN (SELECT id FROM standalone_redis WHERE environment_id = ANY(env_ids)))
      OR (sdb.database_type LIKE '%StandaloneMongodb' AND sdb.database_id IN (SELECT id FROM standalone_mongodbs WHERE environment_id = ANY(env_ids)))
      OR (sdb.database_type LIKE '%StandaloneMysql' AND sdb.database_id IN (SELECT id FROM standalone_mysqls WHERE environment_id = ANY(env_ids)))
      OR (sdb.database_type LIKE '%StandaloneMariadb' AND sdb.database_id IN (SELECT id FROM standalone_mariadbs WHERE environment_id = ANY(env_ids)))
    );

  UPDATE scheduled_tasks st SET team_id = new_team
  WHERE st.team_id = old_team
    AND (st.application_id IN (SELECT id FROM applications WHERE environment_id = ANY(env_ids))
         OR st.service_id IN (SELECT id FROM services WHERE environment_id = ANY(env_ids)));

  UPDATE shared_environment_variables SET team_id = new_team
  WHERE team_id = old_team AND (project_id = project_id_to_move OR environment_id = ANY(env_ids));

  RAISE NOTICE 'Migration of project % to team % completed.', project_id_to_move, new_team;
END $$;

-- Verify before COMMIT:
-- SELECT id, name, team_id FROM projects WHERE id = 10;
-- SELECT id, name, team_id FROM servers WHERE team_id = 11;

COMMIT;
-- On failure: ROLLBACK;

5.4 Pre-migration state check

An overview of resource counts per team (run before migrating an entire team, or before a dry-run):

SELECT 'projects' AS table_name, team_id, count(*) FROM projects GROUP BY team_id
UNION ALL
SELECT 'servers', team_id, count(*) FROM servers GROUP BY team_id
UNION ALL
SELECT 'private_keys', team_id, count(*) FROM private_keys GROUP BY team_id
UNION ALL
SELECT 'github_apps', team_id, count(*) FROM github_apps GROUP BY team_id
UNION ALL
SELECT 's3_storages', team_id, count(*) FROM s3_storages GROUP BY team_id
UNION ALL
SELECT 'tags', team_id, count(*) FROM tags GROUP BY team_id
ORDER BY table_name, team_id;

6. Reverting changes and exceptions

6.1 Revert the localhost server to team 0

If the “localhost” server (id=0) was moved to another team and should remain in team 0:

UPDATE servers
SET team_id = 0
WHERE id = 0 AND team_id = 11;

SELECT id, name, team_id FROM servers WHERE id = 0;

No other table stores the server’s team membership — only servers.team_id. Relations (applications, services, standalone_dockers) reference server_id, so after reverting the team the server keeps working; only its visibility in the Coolify UI changes.

6.2 Reverting an entire migration

If the migration ran in a single transaction and COMMIT was not executed, run ROLLBACK.

If COMMIT was already executed, restoring the previous state requires a database restore from backup, or manual UPDATEs in the opposite direction (using the saved dry-run results and the backup).

7. UI verification and cache

After migration, the project and servers carry the target team_id in the database. In Coolify, switch the team view to the target team (e.g. “A Team”) — the project and servers are visible only there.

If the UI still shows the old state: clear the cache (php artisan cache:clear, php artisan config:clear in the Coolify container), restart the Coolify container, and hard-refresh the browser (Ctrl+Shift+R).

Team access: the user must be in team_user for the target team. Check with:

SELECT * FROM team_user WHERE team_id = 11;

If needed, add a row:

INSERT INTO team_user (team_id, user_id, role) VALUES (11, <user_id>, 'member');

Author

Kacper Zawojski

Senior Full-stack Engineer · Payload expert

Kacper is a senior full-stack developer and Payload CMS expert, with a background in algorithmic computer science from Wrocław University of Technology and a master's in Mechatronics and Robotics from Warsaw University of Technology. He has been building production web products at WAYF since early 2024, working across React, TypeScript, Next.js, and Payload — the stack behind most of our client work.


We're booking content platform
engagements for 2026.

Twenty-five minutes to walk through the work and decide if we're the right team for it. Scoping and a fixed price come after.