How to Deploy Git Projects with the xCloud Public API and MCP
Updated November 3, 2026 · 14 min read
This tutorial shows developers and automation teams how to deploy a supported Git project to an existing xCloud server through either the Public API or xCloud MCP. You will preview repository detection, handle public or private repository access, set the app type and runtime configuration, start an idempotent deployment, poll its status, configure a domain, and recover from a failed attempt. Use the dashboard guide instead if you want to complete the same workflow interactively.
Choose the right path
The dashboard, Public API, and MCP all use the same xCloud deployment capabilities, but they suit different workflows.
| Path | Best for | Starting point |
|---|---|---|
| Dashboard | One-off deployments with a visual form | Deploy Git projects in one click |
| Public API | CI/CD, scripts, and deterministic integrations | https://app.xcloud.host/api/v1 |
| xCloud MCP | Agent-assisted deployment with human approval | https://app.xcloud.host/mcp |
The API and MCP paths below create real sites. Preview the resolved configuration first, then confirm the billable deployment only after you have checked the repository, server, app type, domain, and runtime settings.
Prerequisites
- An xCloud account with access to the target team and server.
- A provisioned xCloud server compatible with the project runtime.
- A Git repository containing a supported WordPress, Laravel, Custom PHP, Node.js, or Lovable project.
- For the Public API: a token with
read:servers,write:servers,read:sites, andwrite:sitespermissions. Follow How to access the xCloud API. - For xCloud MCP: a connected MCP client with full read and write access. Follow How to connect xCloud MCP to your AI agent.
- For a private repository: either a connected Git provider or permission to add an SSH deploy key to the repository.
- For a live domain: access to its DNS records.
- The required environment values, build command, start command, web root, and runtime version for the application.
Deploy through the Public API
This example deploys a Node.js static site to an xCloud staging hostname. Replace every placeholder before running the commands. Keep the API token outside source control and CI logs.
Step 1: Set the API variables
Set the API base URL, token, server UUID, and repository URL in your shell. To find the server UUID and check that the server is connected, use the fleet overview of all servers and sites:
export XCLOUD_API_BASE='https://app.xcloud.host/api/v1'
export XCLOUD_API_TOKEN='xc_live_xxxxxxxxxxxx'
export SERVER_UUID='11111111-1111-4111-8111-111111111111'
export REPOSITORY_URL='https://github.com/your-org/your-app.git'
Expected result: your shell has the values needed by the remaining requests. The token value is not printed.
Step 2: Confirm that the target server is provisioned
List provisioned servers before choosing a target:
curl --fail-with-body -sS \
-H "Authorization: Bearer ${XCLOUD_API_TOKEN}" \
-H 'Accept: application/json' \
"${XCLOUD_API_BASE}/servers?status=provisioned&per_page=100"
Choose a server from data.items and copy its uuid into SERVER_UUID. Check its stack and installed runtime against your project.
Expected result: the selected server has status: "provisioned". Do not silently choose the first server in an automation workflow when several servers are available.
Step 3: Preview repository detection and compatibility
Call the side-effect-free detection endpoint with the intended server:
curl --fail-with-body -sS \
-X POST \
-H "Authorization: Bearer ${XCLOUD_API_TOKEN}" \
-H 'Accept: application/json' \
-H 'Content-Type: application/json' \
"${XCLOUD_API_BASE}/git/detect" \
--data "{
\"repository_url\": \"${REPOSITORY_URL}\",
\"server_uuid\": \"${SERVER_UUID}\",
\"include_deploy_script\": true
}"
Review these response fields before continuing:
| Field | What to check |
|---|---|
data.reachable |
Must be true before xCloud can inspect the repository. |
data.default_branch |
Use this branch unless you intentionally deploy another one. |
data.detection.site_type |
Confirm the detected app type. You can explicitly supply laravel,nodejs,custom-php,wordpress, or lovable. |
data.detection.web_root |
Confirm the directory xCloud should serve, such as dist or public. |
data.detection.build_command and start_command |
Compare the suggestions with the project’s own documentation. |
data.repository_access |
Branch on status,code, and next_actions, not on the prose message. |
data.compatibility.compatible |
Must be true for the native Git path. A Docker server may instead return docker_deployable: true and deploy_via: "docker_compose". |
Expected result: the response identifies a supported app type and a compatible deployment path. Detection is a preview and creates nothing.
Step 4: Preview the resolved site without creating it
Use the auto-deploy endpoint with dry_run: true. The example deliberately sets the important Node.js values instead of relying on every detected default.
curl --fail-with-body -sS \
-X POST \
-H "Authorization: Bearer ${XCLOUD_API_TOKEN}" \
-H 'Accept: application/json' \
-H 'Content-Type: application/json' \
"${XCLOUD_API_BASE}/servers/${SERVER_UUID}/sites/git/auto" \
--data "{
\"dry_run\": true,
\"site_type\": \"nodejs\",
\"repository\": {
\"url\": \"${REPOSITORY_URL}\",
\"branch\": \"main\"
},
\"domain\": {
\"mode\": \"staging\",
\"name\": \"git-api-demo\"
},
\"serving_mode\": \"static\",
\"node_version\": \"20\",
\"install_command\": \"npm ci\",
\"build_command\": \"npm run build\",
\"web_root\": \"dist\",
\"env_file_content\": \"NODE_ENV=production\\nPUBLIC_API_URL=https://api.example.com\",
\"generate_ai_script\": false
}"
The install_command and build_command fields cover this static Node.js build. Use the optional deploy_script field only when the repository needs an additional post-pull command on every deployment, such as npm run postdeploy. Setting it needs the site:manage-deploy-scripts team permission: see Deploy script permission. Keep generate_ai_script: false when you want the explicit configuration above instead of an AI-generated script, and confirm that any named package script exists in the repository before deploying.
Expected result: xCloud returns 200 with data.dry_run: true and a resolved data.would_create preview. No site is created and no idempotency key is consumed.
Step 5: Start the deployment with an idempotency key
After a human checks the dry-run result, send the same configuration without dry_run. Add a unique, stable Idempotency-Key so retrying the request cannot create a duplicate site.
export IDEMPOTENCY_KEY='git-api-demo-2026-09-25-001'
curl --fail-with-body -sS \
-X POST \
-H "Authorization: Bearer ${XCLOUD_API_TOKEN}" \
-H "Idempotency-Key: ${IDEMPOTENCY_KEY}" \
-H 'Accept: application/json' \
-H 'Content-Type: application/json' \
"${XCLOUD_API_BASE}/servers/${SERVER_UUID}/sites/git/auto" \
--data "{
\"site_type\": \"nodejs\",
\"repository\": {
\"url\": \"${REPOSITORY_URL}\",
\"branch\": \"main\"
},
\"domain\": {
\"mode\": \"staging\",
\"name\": \"git-api-demo\"
},
\"serving_mode\": \"static\",
\"node_version\": \"20\",
\"install_command\": \"npm ci\",
\"build_command\": \"npm run build\",
\"web_root\": \"dist\",
\"env_file_content\": \"NODE_ENV=production\\nPUBLIC_API_URL=https://api.example.com\",
\"generate_ai_script\": false
}"
Save data.uuid as SITE_UUID and data.poll_url as POLL_URL from the 202 Accepted response.
Expected result: xCloud accepts one deployment and returns the site UUID, domain, branch, initial status, and polling URL. A 202 means queued, not deployed.
Step 6: Poll until the deployment reaches a terminal state
Request the returned polling URL, waiting for the response’s data.poll_after_seconds value between requests:
export SITE_UUID='22222222-2222-4222-8222-222222222222'
curl --fail-with-body -sS \
-H "Authorization: Bearer ${XCLOUD_API_TOKEN}" \
-H 'Accept: application/json' \
"${XCLOUD_API_BASE}/sites/${SITE_UUID}/status"
Continue until data.terminal is true. Branch on data.deploy_state, which is one of deployed,failed, or cancelled after completion. Also check data.failed_steps: a site can report deployed while still carrying an unrecovered failed step that requires verification.
Expected result:data.deploy_state is deployed,data.terminal is true, and data.failed_steps is empty.
Step 7: Use a live domain and verify DNS
For a live domain, replace the staging domain object before the dry run and deployment:
{
"mode": "live",
"name": "app.example.com",
"ssl_provider": "xcloud"
}
The accepted deployment response includes data.domain_setup.required_dns,verify_url, and next_actions. Add the returned DNS record at your DNS provider, then check resolution:
curl --fail-with-body -sS \
-X POST \
-H "Authorization: Bearer ${XCLOUD_API_TOKEN}" \
-H 'Accept: application/json' \
-H 'Content-Type: application/json' \
"${XCLOUD_API_BASE}/servers/${SERVER_UUID}/dns/check" \
--data '{"domain":"app.example.com"}'
Expected result:data.resolves_to_server becomes true. Follow data.next_actions until it is empty, and keep polling the site while SSL is being issued. If xCloud manages a connected Cloudflare zone, use cloudflare: true in the deploy request instead of manually adding the record.
Deploy a private repository
A private repository requires a connected provider or a verified deploy key. Never paste a private key or personal access token into the deployment payload.
Connected provider path
- Call
GET /integrations/gitand choose a connected provider UUID. - Call
GET /integrations/git/{provider_uuid}/repositoriesto find the repository’sfull_nameand default branch. - Use this repository object in detection and deployment:
{
"provider_uuid": "33333333-3333-4333-8333-333333333333",
"full_name": "your-org/private-app",
"branch": "main"
}
This path can also support enable_push_deploy: true when the connected provider has permission to create the webhook.
SSH deploy-key path
- Prepare a key with
POST /servers/{server_uuid}/git/deploy-keys. - Add only the returned
data.public_keyto the repository’s deploy-key settings. - Verify access:
curl --fail-with-body -sS \
-X POST \
-H "Authorization: Bearer ${XCLOUD_API_TOKEN}" \
-H 'Accept: application/json' \
-H 'Content-Type: application/json' \
"${XCLOUD_API_BASE}/servers/${SERVER_UUID}/git/deploy-keys/44444444-4444-4444-8444-444444444444/verify" \
--data '{
"repository_url":"[email protected]:your-org/private-app.git",
"branch":"main",
"detect":true
}'
- Continue only when
data.verifiedistrue. - Pass the verified key to the deployment:
{
"url": "[email protected]:your-org/private-app.git",
"branch": "main",
"deploy_key_uuid": "44444444-4444-4444-8444-444444444444"
}
If deployment is abandoned, delete the unused key with DELETE /servers/{server_uuid}/git/deploy-keys/{key_uuid}. A private SSH URL without a verified deploy_key_uuid is rejected.
Deploy through xCloud MCP
MCP uses the same Public API contract. Connect your client to https://app.xcloud.host/mcp, or use https://app.xcloud.host/mcp?profile=compact when the client limits the number of tools.
Step 1: Ask the agent to discover the current workflow
Use a prompt that identifies the repository and asks for a preview before any write:
I want to deploy a Node.js project from https://github.com/your-org/your-app.git to xCloud. Find the Git deployment workflow, list my provisioned servers, and analyze the repository against the server I choose. Do not create a site yet.
The agent should call xcloud_agent_search, then use the returned guidance. On the compact profile, the key operation sequence is:
| Stage | MCP operation ID | Compact executor |
|---|---|---|
| List candidate servers | servers.index |
xcloud_execute_read |
| Analyze repository | git.detect |
xcloud_execute_read |
| Deploy with detection | servers.sites.git.auto |
xcloud_execute_destructive |
| Poll deployment | sites.status |
xcloud_execute_read |
| Diagnose failure | sites.deploy-diagnosis |
xcloud_execute_read |
| Read resolved configuration | sites.deploy-config |
xcloud_execute_read |
| Retry a failed deployment | sites.provision-retry |
xcloud_execute_destructive |
Expected result: the agent presents the detected app type, branch, serving mode, commands, web root, server compatibility, domain, and any access actions without creating a site.
Step 2: Approve the exact resolved configuration
After reviewing the preview, give explicit approval with the values you accept:
Deploy the repository to the server I selected as a Node.js static site. Use branch main, Node.js 20, npm ci, npm run build, web root dist, and the staging label git-api-demo. Use an idempotency key. Show me the final request before setting confirm to true.
The agent must use servers.sites.git.auto through xcloud_execute_destructive with confirm: true only after your approval. The operation returns 202 Accepted and a polling URL.
Expected result: the agent reports that xCloud accepted the deployment, then polls sites.status until terminal: true. It does not describe 202 Accepted as a completed deployment.
Step 3: Handle private repository access
For a connected provider, ask the agent to use integrations.git.index and integrations.git.repositories. For an SSH repository, the MCP sequence is:
servers.git.deploy-keys.storethroughxcloud_execute_write.- You add the returned public key to the repository.
servers.git.deploy-keys.verifythroughxcloud_execute_write.servers.sites.git.autowithrepository.deploy_key_uuidafter explicit approval.
The human step cannot be skipped. If verification fails, the agent should stop rather than start cloning.
Verify the deployment
A deployment is complete only when all of these checks pass:
GET /sites/{uuid}/statusreturnsterminal: true.deploy_stateisdeployed.failed_stepsis empty, or each listed step has been reviewed and resolved.- The site’s domain returns the expected application response.
- For a live domain, DNS resolves as expected and the SSL state no longer blocks serving.
- The deployed branch, app type, serving mode, web root, runtime version, and environment configuration match the approved preview.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
401 Unauthorized |
The token is missing or invalid. | Send the token as Authorization: Bearer ... and create a new token if it was revoked. |
403 Forbidden |
The token lacks a required scope or team grant. | Add the required server/site scope and select a team granted to the token. There is no silent team fallback. |
repository_access_probe_unavailable |
The repository host or fallback probe could not answer. This is not proof that the repository is missing. | Retry later, connect a provider, or explicitly supply a verified app type after confirming the repository yourself. |
deploy_key_required |
A private SSH repository has no adopted key. | Prepare the key, add the public half to the repository, verify it, and pass deploy_key_uuid. |
deploy_key_not_verified |
xCloud cannot clone with the prepared key. | Check that the exact public key is attached to the correct repository and verify again. |
incompatible_server |
The selected server cannot run the native deployment. | Use a recommended compatible stack. If detection returns docker_deployable: true, follow the Docker deployment path. |
repository_branch_not_found |
The requested branch does not exist or is not visible. | Use default_branch from detection or select an existing provider branch. |
site_type_undetectable |
Detection could not safely choose an app type. | Set site_type explicitly after reviewing the repository. |
Deployment remains in_progress |
A package install or build can remain at one percentage for more than a minute, or xCloud is about to retry a deploy that never reached the server. | Wait for poll_after_seconds. Inspect events only after terminal failure or no progress for about five minutes. A retry can leave a longer pause with no progress, up to 15 minutes in all: see A deploy that never reached the server. |
deploy_state: failed |
A provisioning, clone, dependency, build, runtime, port, or web-root step failed. | Call GET /sites/{uuid}/deploy-diagnosis, inspect next and next_hint, then read GET /sites/{uuid}/deploy-config. Correct only supported fields and retry the same site with POST /sites/{uuid}/provision-retry. |
422 on deploy_script, or 403 when you change a script |
The caller lacks the site:manage-deploy-scripts team permission. |
Ask the team owner, or a team admin who has it, to grant it, or leave the script out. See Deploy script permission. |
422 with site_type_undetectable and errors.retry_after on sites/git/auto |
The Git host’s rate limit blocked detection. The repository is reachable, and the message names the limit. | Wait for the seconds in errors.retry_after or the Retry-After header, then retry, or set site_type explicitly. |
403 when you send env_file_content or env_file_path |
The caller lacks the site:manage-wpconfig team permission. |
Ask the team owner, or a team admin who has it, to grant it, or leave both fields out. See .env permission. |
429 Too Many Requests |
The API rate limit was exceeded. | Honor Retry-After. Authenticated API requests default to 60 per minute; Git detection is limited to 30 per minute and Git deployment to 20 per minute. |
For detailed task output, list GET /sites/{uuid}/events, then request GET /sites/{uuid}/events/{task_uuid}. Event output is bounded and credential-shaped values are redacted. The default event-detail response returns the end of the output, where failures usually state their cause.
Read why a deploy failed
When a deploy fails, GET /sites/{uuid}/deploy-diagnosis (MCP: sites.deploy-diagnosis) returns a nullable verdict object. It holds the details xCloud stored when that deploy ended, the same failure the site’s Deployments page notice shows.
| Field | What it holds |
|---|---|
uuid |
The ID of this stored result. |
step |
The step that failed: pull, deploy_script, build or start. null when no step can be named. |
cause_excerpt |
About 40 lines of output around the first error, at most 8 KB, with passwords and keys hidden. |
commit_sha |
The commit the deploy checked out, or null. |
created_at |
When the result was stored. |
cause_excerpt is null unless the caller has the site:manage-events team permission. It is also null when next is support, which means no site setting can fix it: stop and contact support.
verdict itself is null when the last deploy did not fail, or when the failed attempt has no stored result, such as one from before this field existed. In the second case, classification and explanation are worked out from the output when you ask.
A deploy that never reached the server
If a deploy can’t reach the server, nothing runs and xCloud does not count it as failed. It tries again after 2 minutes, then 5, then 8, which is 15 minutes in all, and deploy_state stays in_progress while a retry is coming. A newer deploy replaces the retry. Keep polling instead of starting another deploy. deploy_state becomes failed only if the last retry can’t reach the server either.
Deploy script permission
A stored deploy script runs on every later deploy, so setting or changing one needs the site:manage-deploy-scripts team permission. The team owner always has it. Other members need it granted by hand, unless they could already edit deploy scripts before this permission existed.
This permission is needed in addition to the one each request already checks. PUT /sites/{uuid}/git, PUT /sites/{uuid}/deploy-config and POST /sites/{uuid}/provision-retry need site:manage-update, and creating a site needs site:create. Without those, the request returns 403 whatever it changes. The table below is for a caller who has them but not site:manage-deploy-scripts.
Request, without site:manage-deploy-scripts |
Result |
|---|---|
Create a site with a non-empty deploy_script |
422 on deploy_script, with a message naming the permission. |
PUT /sites/{uuid}/deploy-config that changes or clears the script |
403 |
POST /sites/{uuid}/provision-retry with corrections that change or clear the script |
403 |
PUT /sites/{uuid}/git |
403, whatever the request changes. |
Create a site with no deploy_script |
Not blocked by this permission. |
deploy-config or provision-retry with no deploy_script, or the current script byte for byte |
Not blocked by this permission. |
The comparison is exact. A script that differs only in whitespace or line endings counts as a change.
.env permission
Sending env_file_content or env_file_path to PUT /sites/{uuid}/deploy-config, or inside corrections on POST /sites/{uuid}/provision-retry, replaces or moves the site’s .env file. These requests need the same permission as the dashboard’s Environment editor: site:manage-wpconfig, shown as Manage wpconfig in a member’s permissions. They need it in addition to site:manage-update. Without it, the request returns 403. A request that sends neither field isn’t affected.
For what a later Git deployment does to the file, see Environment Variables (.env) and Git Deployments.
Common mistakes
- Treating
202 Acceptedas a successful deployment instead of pollingsites.status. - Deploying without a dry run or repository detection preview.
- Retrying a create request without reusing the same
Idempotency-Key. - Choosing a server silently when automation finds several candidates.
- Trusting a detected build command, start command, or web root without comparing it with the repository.
- Passing secrets in the repository URL, deploy script, command line, or logs.
- Starting a private-repository deployment before the deploy key reports
verified: true. - Parsing human-readable error text instead of stable fields such as
repository_access.code,next_actions,deploy_state, andterminal. - Deleting and recreating a failed site before using deploy diagnosis and
provision-retry.
Frequently asked questions
Does a 202 Accepted response mean the site is deployed?
No. 202 means xCloud queued the deployment. Poll the site status endpoint until terminal is true and deploy_state is deployed, and check that failed_steps is empty.
Can I preview a deployment without creating a site?
Yes. Call the repository detection endpoint for app type, web root and compatibility, then send the auto-deploy request with dry_run set to true. Neither call creates a site or consumes an idempotency key.
How do I deploy a private repository through the API?
Either use a connected Git provider (list providers, pick the repository by full name) or prepare an SSH deploy key on the server, add the returned public key to the repository, verify it, and pass the deploy key UUID in the deployment request. Never send a private key or personal access token in the payload.
Which MCP tool creates the site?
On the compact profile, servers.sites.git.auto runs through xcloud_execute_destructive with confirm set to true, and only after you have approved the previewed configuration. Reads such as git.detect and sites.status run through xcloud_execute_read.
Next steps
- Do it from the dashboard instead: Deploy Git projects in one click, or connect GitHub and deploy Git projects for provider setup and deploy-key recovery in the UI.
- Review the complete xCloud Public API reference.
- Create and scope a token with How to access the xCloud API.
- Connect an AI client with How to connect xCloud MCP to your AI agent, and see what you can ask xCloud MCP to do.
- Grant a token or MCP connection access to several teams when the server lives in another team.
- Let the xCloud Agent Skills run this whole sequence for you in Claude Code.
If a deployment fails in a way the diagnosis endpoint does not explain, feel free to reach out to our support team with the site UUID and the failing event.