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_rsaor 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):
| Parameter | Value |
|---|---|
| Host | localhost |
| Port | 5433 |
| User | (from step 3) |
| Password | (from step 3) |
| Database | coolify |
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
| Table | Notes |
|---|---|
projects | Main table — moving it moves environments and everything under them |
servers | Servers hosting resources |
private_keys | SSH keys |
github_apps | GitHub integrations |
gitlab_apps | GitLab integrations |
s3_storages | Backup storage |
tags | Resource tags |
scheduled_database_backups | Scheduled backups |
scheduled_tasks | Cron tasks |
shared_environment_variables | Shared env variables |
discord_notification_settings | Discord notifications |
email_notification_settings | Email notifications |
slack_notification_settings | Slack notifications |
telegram_notification_settings | Telegram notifications |
pushover_notification_settings | Pushover notifications |
subscriptions | Stripe subscriptions |
team_user | Team ↔ user pivot (optional) |
personal_access_tokens | API 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 moveold_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'); 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.