Files
Abdel Gomez-PerezandAyaan Zaidi ed0df5bf09 fix(backup): preserve symbolic links to external targets (#141925)
Closes #141923

## What Problem This Solves

Fixes an issue where `openclaw backup create` publishes nothing when included state contains an absolute symbolic link to an external file or directory, including a dangling target.

This reworks @adele-with-a-b's reproduction and contribution under the maintainer's explicit recovery policy: preserve links and their targets, report external targets durably, and recreate the links during restore.

## Why This Change Was Made

One archive-entry policy preserves the original link target and classifies whether it names content outside the state directory, even when another declared asset includes the target. The existing traversal records external links. The manifest is sealed after that traversal and includes `externalSymbolicLinks`; no link target is followed to copy content.

The decision `how a symbolic link is recorded and whether its target is external` is made by exactly one mechanism at `src/infra/backup-archive-path-policy.ts:54`.

Restore extracts payload files and creates every link parent directory before creating the verified links. Archive path normalization preserves filename whitespace. Verification rejects entries beneath a symbolic link and checks that external-link records match the archive. Writer and reader share the existing 1 MiB manifest limit; oversized metadata fails before publication.

This intentionally reverses the absolute/escaping-target rejection from #123672 (`8c7e86a2ead`) and the owned-absolute rewrite from #136343 (`0aa9ae9f3e3`). @steipete @vsumner: the maintainer-directed recovery policy now preserves the original target text.

## User Impact

Backups complete with ordinary absolute, relative, and dangling links. The manifest, create summary, JSON output, and restore warnings identify outside-state targets. Creation never copies content through a link; a separately declared config, credentials, or workspace asset can still include that content. Absolute links still name their original locations after restore; review those paths before activating a relocated backup.

No database schema, migration, config key, CLI flag, or manifest version changes. No installation/update behavior change is claimed. Existing managed-root exclusions and SQLite snapshot/sanitization owners remain in place.

Compatibility findings: v2026.9.4 rejects preserved absolute or escaping targets. Such archives require the updated reader. Older portable relative archive links remain supported, including Windows links between drives. Absolute owned links are no longer rewritten for relocation, as required by literal target preservation. The manifest now appears after the payload and always includes an external-link report, which can be empty. Readers retain the older portable-relative archive contract when that report is absent. Old cross-asset links are still reported as outside-state during verification and restore.

## Evidence

- Current head `712293765774e11ac6b01ca0ce3fb839d98e5e97` adds one required test diagnostic label to the unchanged production implementation. CI caught the omitted second argument to `expectDefined`; both affected command cases and formatting pass after correction. Fresh source review confirms reuse of the production and independent proof below with their original tested identities. Corrected-input CI must establish type validity.

- Production proof on `a634bd60fa5e1dbb361c8ee5d3c77c07d418ed27`: rebuilt production CLI create/verify/restore and text create all exit 0; four separately included outside-state links produce four manifest/output records and preserve literal restored targets. The actual v2026.9.4 source build restores its ordinary archive with exact bytes, and rejects its absolute-link archive before creating the target.

- Census correction: the prior candidate preserved four links to separately included outside-state config, credentials, and workspace targets, but reported zero. Production CLI proof captures that failure. The corrected shared policy uses the state boundary and preserves literal targets.
- The writer seals the canonical state directory used by inventory. A command regression reproduced a verification mismatch when an enclosing workspace covers state accessed through an alias; the canonical manifest fact resolves it.

- Pinned main `0a991fdc6423686210830b7b582ea482174686fd`: production CLI `backup create --verify --json` exits 1 on the external file link; no archive published.
- Committed candidate `23ace9e1304471b162bad241fc32aaf18e9c7080`, with subsequent CI and containment corrections recorded below: production CLI creation and verification succeed; four link entries preserve targets; three external targets appear in the manifest and output.
- Previously reviewed candidate `dde971d1d42d24560160d62d50140665b150e441`: fresh independent CLI proof passes on its exact build. Seven links preserve targets, including trailing-space names and both dangling forms; all four external links are reported. Internal links read restored bytes after source mutation; external content stays absent. Both ordinary and space-suffixed crafted descendants are refused before target creation, with the complete external directory unchanged.
- v2026.9.4-created archive restores on candidate. The actual v2026.9.4 source build restores the candidate no-link archive (exit 0), but rejects the candidate external-link archive before extraction (exit 1: symbolic link target must be relative).
- No-link control on final head: payload paths, types, modes, targets, and bytes match pinned base after normalizing run identity. Manifest differences are its final position and an explicit empty `externalSymbolicLinks` report. The actual v2026.9.4 source reader restores this new ordinary archive byte-for-byte.
- Hardlink control: 1,000 hardlinks stall on the same entry at the unchanged 300000ms watchdog on pinned main and candidate. This is the adjacent #145429 failure; no hardlink fix is claimed.
- Third-party SQLite: baseline and candidate creation pass; candidate restore preserves the original vendor table row.
- Manifest-limit stress: 1,300 links with long targets exceed the existing 1 MiB manifest bound. Creation exits 1 with an explicit size error and publishes no archive.
- Focused create/verify/restore owner tests pass. Four stale command tar mocks were repaired; the command and atomic-publication sibling run then passed all 23 tests. Scoped production/test lint, formatting, line limits, assertion checks, and unused exports pass. All four structural checks also pass on the exact merge tree. The unused-export scan needed a frozen workspace install to load its browser-test dependency; the original setup failure is retained. Full typechecks and suites remain CI-owned.
- First-head CI exposed stream/filter type mismatches; the correction uses the producer’s `AsyncIterable<Buffer>` contract and the verifier’s recorded link paths. Local typechecks remain disabled; exact-head CI owns that proof.
- Live review found a space-suffixed link ancestor bypass. Production CLI reproduction confirmed an external write on the prior candidate. The corrected normalizer retains filename spaces, and restore finishes all parent directories before exclusive link creation. Restore/verify/atomic tests pass: 76, with one existing platform skip.
- Supplemental census-correction proof on `00f727bb6da9e1f8e09f0a19f87ef4e40576806a`: a fresh source-blind validator exercised 22 CLI invocations. Nine outside-state first hops were reported across all outputs; 11 literal links and separately declared asset bytes survived. Aliased state inside an enclosing workspace, old portable cross-asset archives, malformed report refusals, both link-descendant refusals, and the previous-stable artifact all passed.
- Execution proof is macOS arm64 only. Linux and native Windows restoration remain unproven.

## Consumers

| Reader or writer | Disposition |
| --- | --- |
| `register.backup.ts` → `backupCreateCommand` → `createBackupArchive` | Same CLI path and options; writer retains links and emits report. |
| `backup-archive-path-policy.ts` | One link policy replaces the remapper plus rejecting guard. Entry-path containment remains separate from link-target text. |
| `backup-create-stream.ts` | Streams payload once and appends the finalized manifest before gzip completes. |
| `backup-verify-manifest.ts` | Reads additive external records; owns the shared manifest byte limit. |
| `backup-verify.ts` listing/verification | Existing tar listing is the metadata census; uses the same link policy, matches report, and rejects link ancestors. |
| `backup-restore.ts` | Consumes verified link records after ordinary extraction and all-parent-directory preparation; recreates exact targets with exclusive creation. |
| `migrate/apply.ts` pre-migration backup | Uses the same verified backup; report survives inside its archive even though create text is suppressed. |
| `backup-health.ts`, `backup-run-records.ts`, status/Doctor | Existing success/failure receipt contract remains unchanged; no separate archive parser. |
| `scripts/e2e/lib/external-package-transition.sh` | Calls the registered CLI and retains JSON; it does not parse the manifest or link records. This external-install harness is not an updater proof. |
| `src/infra/backup-create.test.ts`, `src/commands/backup-verify.test.ts`, `src/commands/backup-restore.test.ts` | Direct typed owner-test readers. The test ledger replaces target rejection and rewriting with literal-target, external-report, and restored-filesystem assertions through the registered command entry points; containment and older-reader cases remain covered. |
| `src/cli/program/register.backup.product-path.test.ts:13` | Launches the actual create CLI and verifies completion when SQLite capture outlives the audit lease. The existing `backup-cli-registration` lane collected its passing case in job 103526025671, log line 11081. |
| `src/cli/help-exit.process.test.ts:451` | Launches actual create CLI cases for an absolute configured-config link and excluded workspace. `test/vitest/vitest.cli-process-paths.mjs:10` assigns it to the separate process group. Both cases passed in job 103526024850, log lines 1593–1594; required receipt `backup-cli-process` binds this consumer and output. |
| `backup-capture-privacy.test.ts`, `backup-create.sqlite-hardlinks.test.ts`, `backup-config-capture.test.ts`, `backup-create.legacy-audit-lease.test.ts` | Typed-reference census found existing test consumers. Calls and inputs remain supported; core owner and hardlink/SQLite endpoint evidence cover the touched archive behavior. Their full CI test lanes remain required. |
| `backup.test-support.ts`, `backup.test.ts`, `backup.atomic.test.ts`, `backup.create-verify.test.ts` | The mock stream contract was updated; finished-archive assertions replace temporary-manifest callback inspection. All 23 command/atomic sibling tests pass. |
| `src/commands/backup-shared.ts` | Unchanged shared payload encoder and plan fields remain supported. Caller canonicalization supplies inventory.stateDir; raw plan.stateDir continues serving existing config/lease planning. The new manifest/report boundary consumes the canonical inventory path without changing the planner contract. |
| `src/commands/backup-resource-inventory.ts` | Unchanged stateDir declaration remains the canonical inventory boundary. New writer metadata reuses this authoritative fact rather than creating a competing state owner. |
| `src/infra/backup-sqlite-snapshot.ts` | Unchanged inventory.stateDir consumers classify snapshot paths, locate gateway coordination, walk the canonical state tree and set isolated snapshot environment. Reporting reuse does not alter these snapshot/lease side effects or database ownership. |
| SQLite backup list/verify/restore, Git backups, Fleet and skill collection backups | Separate artifact formats; unchanged. There is no native whole-archive list command. |
| `docs/cli/backup.md` | Documents literal link targets, durable reporting, extraction order, and the previous-reader compatibility limit. |
| `docs/install/backups.md` | Removes the unqualified portable-archive promise; states that absolute links retain original locations, including separately backed-up config/credentials, and need review before relocated activation. Links the detailed CLI caveat. |
| `docs/install/migrating.md` | Machine-move instructions now require reviewing original absolute targets, including separately backed-up config/credentials, before activation at another location. Links the detailed CLI caveat. |
| `docs/install/digitalocean.md`, `docs/install/oracle.md`, `docs/install/raspberry-pi.md` | Deployment backup instructions replace unqualified portability claims with archive transfer/staging and review of original absolute targets before activation, including separately backed-up config/credentials. Each links the detailed CLI caveat. |
| `docs/install/updating/rollback-and-recovery.md:195` | Unchanged explicit pre-update backup command and manifest/source-path guidance. It does not promise automatic link relocation or exercise the installed-software updater. |
| `docs/cli/reset.md:40`, `docs/cli/uninstall.md:34` | Unchanged commands recommend an archive before removal and use supported create options; no archive parser or relocation rule. |
| `docs/cli/migrate.md:78` | Unchanged description of the verified pre-migration backup uses the same create command; no separate format or link policy. |
| `docs/plugins/manifest/surfaces.md:174` | Unchanged `--only-config` example and plugin resource inclusion contract; link preservation does not change manifest resource discovery. |
| `docs/cli/doctor/sqlite-maintenance.md:32,70` | Backup-advice consumers are outside the explicit Doctor-advice contract; maintenance wording remains unchanged. |
| `docs/releases/2026.7.1.md` | Historical release statements are records of shipped behavior, not current operator instructions to rewrite. |

The process receipt supplements the retained prior-head evidence for run 34683341770, attempt 1, event `pull_request`: `checks-node-compact-small-12` / `agentic-cli-process-hosted-6` passed eight files with one skipped and 53 tests with five skipped (job 103526024850, log lines 1629–1630). The corrected receipt set includes required lane `backup-cli-process`; registration proof already includes the product-path process case. These receipts do not claim execution of a later revision.

The preceding receipt plan bound head `a634bd60fa5e1dbb361c8ee5d3c77c07d418ed27`, merge `665241f7662257d8339b71b79fd28dab939aa268`, and run 34685894510 attempt 1. The writer lane is `checks-node-compact-large-36` (103532821764); command consumers moved to `checks-node-compact-small-4` (103532820738); registration uses `checks-node-compact-small-9` (103532822348); the separate required `backup-cli-process` lane uses `checks-node-compact-small-12` (103532821731). That run exposed the missing test-helper argument in `check-test-types-core-1`; it cannot supply a passing type receipt. Run 34686603366 on the corrected head must supply fresh completed output for the same 15 required structural and affected lanes. Prior receipt identities remain historical, not current-head success.

Unchanged-helper reference expansion is explicitly dispositioned here. The queried `writeJson` and `realpathSync` contracts are unchanged in `src/infra/json-files.ts`, `src/infra/json-files.test.ts`, `src/infra/install-package-dir.ts`, `src/infra/npm-managed-root.ts`, `src/skills/loading/workspace-skill-sync.runtime.ts`, `src/skills/lifecycle/clawhub-store.ts`, `src/skills/lifecycle/source-install.ts`, `src/agents/cli-runner/bundle-mcp-runtime.ts`, `src/cli/update-cli/update-command-post-core.ts`, `scripts/lib/package-dist-inventory.ts`, `src/plugin-sdk/json-store.ts`, `src/wizard/setup.migration-promotion.ts`, `src/gateway/test-helpers.config-runtime.ts`, and `src/gateway/test-helpers.server.ts`. Dependency declarations `@openclaw/fs-safe/dist/json.d.ts` and `@types/node/fs.d.ts` are also unchanged. These are helper-reference results, not affected archive consumers; no SDK or updater change or shipped-consumer proof is requested for them. CLI startup/config-preflight readers that only recognize unchanged command spelling likewise do not consume the archive contract. No additional affected native app, channel, plugin runtime, SDK export, locale, or persistence consumer was found in the inspected source/lexical closure.

Removed `assertArchiveSymbolicLinkTarget` had only native create and verify consumers; both use `recordArchiveSymbolicLink`. The private absolute remapper is deleted. No removed policy remains reachable.

Typed census: pinned-base and final merge-tree runs use explicit `tsconfig.core.json`, `test/tsconfig/tsconfig.core.test.commands.json`, and `test/tsconfig/tsconfig.core.test.infra.json` projects. Selected declarations and import bindings are covered in the loaded projects; the raw parsed JSON property has an explicitly recorded dynamic-query gap. All returned reader locations have retained dispositions, including unchanged `writeJson` and `realpathSync` helper users. Unloaded projects and sources remain explicit coverage gaps; no whole-repository typed coverage is claimed. The retained prior-head 86-position batch ran on CI merge `f7c0b811779ffbbca93b2e3047807d8683a1bfba`; all three projects loaded and analysis exited 0. The lexical census found no remaining reader of the removed policy, no manual rows, and no generic exemptions.

The shared filename normalizer also serves manifest asset membership, SQLite snapshot-root discovery, duplicate/portable-collision checks, and older rootless hardlink lookup. Each now retains significant whitespace; declaration/import and individual reader dispositions are retained with the typed results. The ordinary and trailing-space hardlink tests remain green.

<details>
<summary>Typed coverage gaps</summary>

Unloaded projects (136): `extensions/{a2a, acpx, admin-http-rpc, alibaba, amazon-bedrock-mantle, amazon-bedrock, anthropic-vertex, anthropic, arcee, azure-speech, baseten, brave, browser, buzz, byteplus, cerebras, chutes, clawrouter, clickclack, cloudflare-ai-gateway, codex, cohere, comfy, copilot-proxy, copilot, deepgram, deepinfra, deepseek, diagnostics-otel, diagnostics-prometheus, diffs, discord, duckduckgo, elevenlabs, exa, fal, featherless, feishu, firecrawl, fireworks, geolocation, github-copilot, gmi, google-meet, google, googlechat, gradium, groq, huggingface, image-generation-core, imessage, inworld, irc, kilocode, kimi-coding, line, litellm, llm-task, lobster, longcat, matrix, mattermost, memory-core, memory-lancedb, memory-wiki, meta, microsoft-foundry, microsoft, minimax, mistral, moonshot, msteams, mxc, nextcloud-talk, nostr, novita, nvidia, ollama, openai, opencode-go, opencode, openrouter, openshell, parallel, perplexity, pixverse, qa-channel, qa-lab, qianfan, qwen, raft, reef, runway, searxng, sglang, signal, slack, sms, stepfun, synology-chat, synthetic, tavily, teams-meetings, telegram, tencent, tlon, together, tokenjuice, twitch, venice, vercel-ai-gateway, vllm, voice-call, volcengine, vydra, webhooks, whatsapp, xai, xiaomi, zai, zalo, zalouser, zoom-meetings}/tsconfig.json`; `extensions/tsconfig.json`; `extensions/tsconfig.package-boundary.base.json`; `extensions/tsconfig.package-boundary.paths.json`; `packages/ai/tsconfig.json`; `packages/llm-core/tsconfig.json`; `packages/model-catalog-core/tsconfig.json`; `packages/plugin-sdk/tsconfig.json`; `test/tsconfig/tsconfig.extensions.test.json`; `tsconfig.extensions.json`; `tsconfig.extensions.projects.json`; `tsconfig.json`; `tsconfig.scripts.json`; `tsconfig.ui.json`. These projects were not selected and remain explicit coverage gaps.

</details>

The retained prior-head merge census has 441 distinct symbol/location records, each with a retained semantic disposition. Of 258 project-position queries, 211 resolve and 47 are outside one selected project but covered by another; no position is wholly unresolved. All 13 files from that prior head had empty candidate-to-merge diffs. The lexical PR inventory uses final rebase base `0ffc3f0fb17f43563ea42fcd3a10fa1af31a98ab`; the original failure baseline remains pinned separately.

Supplemental correction BASE census: 48 positions on the prior tested merge, whose affected owner files match `dde971d1`; all 144 project queries resolve, with 253 distinct reader dispositions across 11 files. No projects failed. This supplements the immutable original baseline census. The new merge-tree query and its exact coverage are recorded below.

Previous-candidate typed census on merge `665241f7662257d8339b71b79fd28dab939aa268`, tree `0c10bb805dde42acfbe16cce50587b595f120690`, for head `a634bd60fa5e1dbb361c8ee5d3c77c07d418ed27`: 146 freshly selected positions cover the original 86-position batch, the 48-position correction BASE batch, and new state/report-presence declarations and aliases. All three selected projects loaded and analysis exited 0 with no project failures. The 3,080 returned references reduce to 758 distinct symbol/location records across 23 files; every record has a retained source-based disposition.

Of 438 project-position queries, 382 resolve. Another 53 are outside one selected project and resolve in a named sibling project. Three query instances are the same raw JSON position, `src/commands/backup-verify-manifest.ts:187:16`: `parsed.externalSymbolicLinks` has no declared member symbol because `parsed` is narrowed to `Record<string, unknown>`. This is an explicit dynamic-field coverage gap. The parser function, `parsed` local, `BackupManifest.externalSymbolicLinks` declaration, and returned shorthand at :187:61 resolve. Source review traces the presence check at :150/:187: an absent field stays absent; a present array is validated and copied. The verifier consumes that distinction at `src/commands/backup-verify.ts:672,684`; command tests distinguish absent, empty, and populated reports at `src/commands/backup-verify.test.ts:1542,1555,1574`. No unresolved export edge was returned.

The 136 unloaded projects remain explicit coverage gaps. All 66,307 source-outside-project records remain named by project without truncation in the coverage artifact; this includes overlapping exclusions and is not a distinct-file count. Native code, docs, configuration, process-launch strings and dynamic wire keys remain manual-census surfaces. No whole-repository typed coverage is claimed.

All 17 PR files and all 23 returned-reader files have empty candidate-to-merge diffs. Six dependency manifests/project configs and 95 direct relative imports also match. The merge contains 353 other changed files, retained in the dependency record; no whole-tree or complete transitive runtime equivalence is claimed. The original failure BASE and supplemental prior-candidate BASE remain separate evidence.

Fresh typed analysis completed on merge `c044a4f1d0ed93856127e2d72c2e69e362a37c39`, tree `d0111aa9cfce690f1f98e825012d0e6f21d890cb`, for head `712293765774e11ac6b01ca0ce3fb839d98e5e97`. Its 146 checked positions retain the original 86-position scope, the 48-position supplemental BASE scope, and new canonical-state/report-presence declarations and aliases. All three selected projects loaded; exit 0, no project failures. The 3,080 raw references reduce to 758 distinct symbol/location records across 23 files. Every returned record has a fresh source-based disposition with its full query/project origins and enclosing context retained.

Of 438 project-position queries, 382 resolve. Another 53 miss within one selected project but resolve in a named sibling project. Three instances represent one persistent dynamic-field gap at `src/commands/backup-verify-manifest.ts:187:16`: `parsed.externalSymbolicLinks` is a raw wire member on `Record<string, unknown>`, so it has no declared member symbol. The parser function, `parsed` local, typed manifest property and returned shorthand at :187:61 resolve. Manual source review confirms the checks at :150/:187 preserve absent versus present reports; verifier lines :672/:684 consume that distinction. Verifier tests at :1542/:1555/:1574 cover empty, missing and populated reports. No unresolved export edge was returned; all 133 export records retain dispositions.

The 136 unloaded projects remain named coverage gaps. All 66,315 source-outside-project records are retained without truncation, including overlapping project exclusions; this is not a distinct-file count. The selected scope is `tsconfig.core.json`, `test/tsconfig/tsconfig.core.test.commands.json` and `test/tsconfig/tsconfig.core.test.infra.json`. Native code, docs, configuration, process-launch strings and dynamic wire keys remain manual-census surfaces. This is not whole-repository typed coverage.

All 17 task files, all 23 returned-reader files, six dependency/configuration files and 95 direct relative-import edges (54 unique target files) match between candidate and merge. The merge also contains 391 other file changes, listed in the dependency record. The comparison establishes no whole-tree or complete transitive-runtime equivalence. The original pinned failure BASE and supplemental BASE evidence remain unchanged and separately identified.

The only candidate change since the previous census adds the required `expectDefined` context argument in the restored workspace-file assertion. Fresh positions were checked against the new merge, and later returned test locations were reviewed with their one-line shift. This correction adds no production contract. Previous failed type evidence remains failed; corrected-head CI is separate required evidence.

Current receipt plan: head `712293765774e11ac6b01ca0ce3fb839d98e5e97`, merge `c044a4f1d0ed93856127e2d72c2e69e362a37c39`, run 34686603366 attempt 1. Writer job 103534715513, command job 103534714257, registration job 103534716527, process job 103534715637, and corrected infra-type job 103534712831 are explicitly required. All 15 structural and affected lanes need successful completed output; the plan is not a success receipt.

Pipeline endpoint: restored filesystem entries, their exact `readlink` values, readable internal content, and untouched external content.

## Invalidation

No persistent cache is added. The external-link list belongs to one archive attempt and is cleared before each existing retry. The final manifest derives from observed write entries. Verification derives new link records from the selected archive for each invocation; restore consumes those verified records.

## Contention

No new shared lock, queue, or database transaction is held. Existing SQLite/audit snapshot capture completes before tar traversal. Compression, temporary output, and the link report belong to one archive operation.

## Tests

New and rewritten tests name `backupCreateCommand`, `backupVerifyCommand`, and `backupRestoreCommand`. Coverage includes separately included outside-state config/credentials/workspace targets (absolute and relative), an aliased state directory covered by a workspace, absent versus empty legacy reports, file/directory/dangling/internal/external links, exact manifest records, old Windows cross-drive links, managed-skill non-promotion, literal backslashes, and refusal of files or links beneath an archived symlink. Existing hardlink and SQLite coverage remains.

Test deletion ledger: obsolete target-rejection and owned-target-rewrite expectations are replaced by literal-target and restored-file assertions in the same owner tests. No whole test file is removed.

## Context

Related #145429 remains a draft hardlink-stall repair. This diff does not change its link-cache or traversal policy, but both PRs edit the tar-options owner in `src/infra/backup-create.ts`: #145429 replaces the link-cache helper and adds serial hardlink options. There is no semantic landing order. Whichever lands second must preserve both the hardlink options and this manifest/link path, then prove the combined writer. #144552, #107433, and #40786 are adjacent issues outside this repair.

Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-09-12 15:51:07 +05:30

5.9 KiB

summary, read_when, title
summary read_when title
Host OpenClaw on a DigitalOcean Droplet
Setting up OpenClaw on DigitalOcean
Looking for a simple paid VPS for OpenClaw
DigitalOcean

Run a persistent OpenClaw Gateway on a DigitalOcean Droplet (~$6/month for the 1 GB Basic plan).

DigitalOcean is a straightforward paid VPS path. For cheaper or free options:

  • Hetzner -- more cores/RAM per dollar.
  • Oracle Cloud -- Always Free ARM tier (up to 4 OCPU, 24 GB RAM), but signup can be finicky and it is ARM-only.

Prerequisites

  • DigitalOcean account (signup)
  • SSH key pair (or willingness to use password auth)
  • About 20 minutes

Setup

Use a clean base image (Ubuntu 24.04 LTS). Avoid third-party Marketplace 1-click images unless you have reviewed their startup scripts and firewall defaults.
1. Log into [DigitalOcean](https://cloud.digitalocean.com/).
2. Click **Create > Droplets**.
3. Choose:
   - **Region:** Closest to you
   - **Image:** Ubuntu 24.04 LTS
   - **Size:** Basic, Regular, 1 vCPU / 1 GB RAM / 25 GB SSD
   - **Authentication:** SSH key (recommended) or password
4. Click **Create Droplet** and note the IP address.
```bash ssh root@YOUR_DROPLET_IP
apt update && apt upgrade -y

# Install Node.js 24 LTS
curl -fsSL https://deb.nodesource.com/setup_24.x | bash -
apt install -y nodejs

# Install OpenClaw; run onboarding later as the non-root owner.
curl -fsSL https://openclaw.ai/install.sh | bash -s -- --no-onboard

# Create the non-root user that will own OpenClaw state and services.
adduser openclaw
usermod -aG sudo openclaw
loginctl enable-linger openclaw

su - openclaw
openclaw --version
```

Use the root shell only for system bootstrap. Run OpenClaw commands as the non-root `openclaw` user so state lives under `/home/openclaw/.openclaw/` and the Gateway installs as that user's systemd `--user` service.
```bash openclaw onboard --install-daemon ```
The wizard walks you through model auth, channel setup, Gateway token generation, and daemon installation (systemd user service).
```bash sudo fallocate -l 2G /swapfile sudo chmod 600 /swapfile sudo mkswap /swapfile sudo swapon /swapfile echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab ``` ```bash openclaw status systemctl --user status openclaw-gateway.service journalctl --user -u openclaw-gateway.service -f ``` The Gateway binds to loopback by default. Pick one of these options.
**Option A: SSH tunnel (simplest)**

```bash
# From your local machine
ssh -L 18789:localhost:18789 root@YOUR_DROPLET_IP
```

Then open `http://localhost:18789`.

**Option B: Tailscale Serve**

```bash
curl -fsSL https://tailscale.com/install.sh | sudo sh
sudo tailscale up
openclaw config set gateway.tailscale.mode serve
openclaw gateway restart
```

Then open `https://<magicdns>/` from any device on your tailnet.

Tailscale Serve authenticates Control UI and WebSocket traffic via tailnet identity headers, which assumes the Gateway host itself is trusted. HTTP API endpoints still follow the Gateway's normal auth mode (token/password) regardless. To require explicit shared-secret credentials over Serve, set `gateway.auth.allowTailscale: false` and use `gateway.auth.mode: "token"` or `"password"`.

Persistence and backups

OpenClaw state lives under:

  • ~/.openclaw/ -- openclaw.json, channel/provider credentials, shared and per-agent SQLite auth stores, and session data.
  • ~/.openclaw/workspace/ -- the agent workspace (SOUL.md, memory, artifacts).

These survive Droplet reboots. To create a backup archive:

openclaw backup create
openclaw backup restore <archive.tar.gz> --target <fresh-directory>

DigitalOcean snapshots back up the whole Droplet. OpenClaw archives can be transferred to another host. Absolute symbolic links keep their original target locations, including links to separately backed-up config or credentials. Review these links before activating state on another host or at another path; see the backup symbolic-link caveat. Restore verifies and extracts into a fresh staging directory; activation is a separate offline step. See Restore a full archive for the rollback warnings and activation sequence.

1 GB RAM tips

The $6 Droplet only has 1 GB RAM. To keep things smooth:

  • Make sure the swap step above is in /etc/fstab so it survives reboots.
  • Prefer API-based models (Claude, GPT) over local ones -- local LLM inference does not fit in 1 GB.
  • Set agents.defaults.model.primary to a smaller model if you hit OOMs on large prompts.
  • Monitor with free -h and htop.

Troubleshooting

Gateway will not start -- Run openclaw doctor --non-interactive and check logs with journalctl --user -u openclaw-gateway.service -n 50.

Port already in use -- Run lsof -i :18789 to find the process, then stop it.

Out of memory -- Verify swap is active with free -h. If still hitting OOM, switch to API-based models (Claude, GPT) rather than local models, or upgrade to a 2 GB Droplet.

Next steps