Skip to content

QNAP / Synology NAS deployment ​

Deploy a CargoShip fleet writer on a NAS: an unattended, write-only agent that backs up the NAS's own directories to S3 on a schedule, pulls a signed config from S3, and cannot read back, decrypt, or delete anything — not even its own data.

Written for QNAP Container Station; Synology Container Manager is the same picture with its own volume paths. You drive setup from a control machine (your laptop) and the NAS only ever runs the agent.

Incremental sync works under the strict write-only policy

A strict write-only identity cannot read its previous manifest from S3. CargoShip therefore keeps a local manifest cache: after each successful cycle it records what it stored, and the next cycle diffs against that instead of treating every file as new. So a write-only agent uploads only the delta — no read permission required.

The cache lives in the container's cargoship-state volume (~/.cargoship/manifest-cache), which is why the compose file mounts it: delete that volume and the next cycle is a full sync again.

Two things to know:

  • S3 always wins. The cache is only consulted when the previous manifest cannot be read. It is a fallback, never an override.
  • It is trusted for 30 days. The cache cannot tell that objects it vouches for were deleted or expired by a lifecycle rule, so it expires and forces a periodic full sync that re-establishes what is really there. A quiescent dataset therefore does one full cycle a month by design.

Use --no-manifest-cache to switch it off (every cycle becomes a full sync), or --force on a single run to rebuild from scratch if you ever suspect the cache. The heartbeat reports which source each cycle used — delta_source is s3, cache or none; a writer stuck on none is re-uploading everything.

The non-strict policy is still an option

If you would rather the agent read its own manifest from S3 than rely on local state, emit the non-strict policy (drop --write-only):

bash
cargoship ghostship iam-policy s3://my-bucket/nas --writer-id lab-nas-1 \
  --kms-key-arn arn:aws:kms:… > iam-policy.json

That grants GetObject on its own prefix only (plus kms:Decrypt on the one fleet key). It is still delete-free and still cannot touch another writer's data. This is no longer required for incremental sync — it is a choice between trusting S3 reads and trusting local cached state.

Prerequisites ​

  • On the NAS: Container Station (QNAP) or Container Manager (Synology), and SSH.
  • On your control machine: cargoship installed (install), AWS credentials that can create IAM identities (or your own IAM tooling), and an existing S3 bucket.
  • The NAS needs outbound HTTPS only. No inbound port, no port forwarding.

Architecture is handled for you

The published image is a multi-arch manifest (linux/amd64 + linux/arm64), so the same reference works on older Intel NAS boxes and newer ARM ones. You no longer build or transfer an image — that is what the old version of this guide did.

Container Station's docker binary is not on PATH for SSH sessions. On QNAP it is typically /share/ZFS530_DATA/.qpkg/container-station/bin/docker — the exact volume name varies, so find it with ls -d /share/*/.qpkg/container-station/bin/docker. Below, docker means that path.

1. Create the fleet signing key (control machine, once) ​

The operator signs configs; the NAS only ever holds the public half.

bash
cargoship ghostship config-keygen --out ./fleet-keys

Keep config-signing-private.pem off the NAS. Anyone holding it can tell your fleet what to back up.

2. Scaffold the writer bundle (control machine) ​

bash
cargoship ghostship init s3://my-bucket/nas \
  --writer-id lab-nas-1 \
  --kms-key-arn arn:aws:kms:us-west-2:123456789012:key/abcd-1234 \
  --public-key ./fleet-keys/config-signing-public.pem \
  --region us-west-2 \
  --out ./lab-nas-1

You get iam-policy.json, a config.yaml skeleton, the baked public key, a compose.yaml that already references the published image, and a README.

Add --mint if your credentials have iam:Create* and you want CargoShip to create the IAM user, attach the policy, and write aws-credentials (mode 0600) into the bundle for you. Otherwise attach iam-policy.json to a new IAM identity yourself and drop its key into ./lab-nas-1/aws-credentials in standard credentials-file format.

3. Fill in and sign the config (control machine) ​

Edit ./lab-nas-1/config.yaml so watch_paths names the NAS paths to back up, as the container will see them:

yaml
id: lab-nas-1
writer_id: lab-nas-1
version: 1
s3_config:
  bucket: my-bucket
watch_paths:
  - path: /volume1/research-data
    storage_class: STANDARD_IA
    exclude_patterns: ["#recycle", "@eaDir", ".aws", ".ssh", ".DS_Store"]
  - path: /volume1/Documents
scan_interval: 1h

On a NAS the exclusions matter more than anywhere else. #recycle (Synology) and @Recycle (QNAP) hold already-deleted files and are routinely the largest thing on a volume — on one real deployment, 20 GB of a 26 GB home directory. @eaDir is Synology thumbnail and index metadata, regenerated locally and nested throughout the tree. And .aws/.ssh should never reach an archive at all.

A bare pattern matches any path segment, so it excludes the directory and everything under it; see the watch_paths reference for the exact rules.

Check it, then sign it:

bash
cargoship ghostship validate-config ./lab-nas-1/config.yaml
cargoship ghostship sign-config ./lab-nas-1/config.yaml \
  --key ./fleet-keys/config-signing-private.pem

Upload the config and its signature to the control prefix:

bash
aws s3 cp ./lab-nas-1/config.yaml     s3://my-bucket/nas/fleet/lab-nas-1/config.yaml
aws s3 cp ./lab-nas-1/config.yaml.sig s3://my-bucket/nas/fleet/lab-nas-1/config.yaml.sig

4. Add the data mounts ​

The emitted compose.yaml has a commented placeholder. Mount every watch_paths directory read-only, at the same path the config names:

yaml
    volumes:
      - ./config-signing-public.pem:/etc/cargoship/config-signing-public.pem:ro
      - ./aws-credentials:/home/cargoship/.aws/credentials:ro
      - cargoship-state:/home/cargoship/.cargoship
      - /volume1/research-data:/volume1/research-data:ro   # add these
      - /volume1/Documents:/volume1/Documents:ro

Read-only is deliberate: the agent only ever reads your data.

Keep the cargoship-state volume. It holds resume state and the manifest cache, so on a write-only writer losing it costs a full re-upload of everything — not just a re-scan. If you recreate the container, keep the volume.

5. Deploy (on the NAS) ​

bash
ssh admin@nas.local "mkdir -p ~/cargoship"
scp -r ./lab-nas-1/* admin@nas.local:~/cargoship/

# Make the credentials readable by the container user — see the warning below.
ssh admin@nas.local "sudo chown 65532:65532 ~/cargoship/aws-credentials"

ssh admin@nas.local "cd ~/cargoship && \
  /share/ZFS530_DATA/.qpkg/container-station/bin/docker compose up -d"

The chown is not optional

aws-credentials is written 0600 owned by whoever ran ghostship init. The agent runs as uid 65532, so without the chown it cannot read the file, starts with no credentials, and crash-loops — reporting an EC2 IMDS timeout that mentions neither the file nor permissions (#692):

failed to refresh cached credentials, no EC2 IMDS role found,
operation error ec2imds: GetMetadata, request canceled, context deadline exceeded

Keep the mode at 0600 — change the owner, not the permissions. Current releases fail fast with a message naming the file, but older agents show only the IMDS error.

Synology: it is docker-compose, not docker compose

On DSM, Compose is a standalone binary, not a docker subcommand (#694):

bash
/usr/local/bin/docker-compose -p lab-nas-1 up -d     # Synology DSM
docker compose up -d                                  # QNAP Container Station

On both, docker is not on PATH for non-interactive SSH, so use the absolute path (/usr/local/bin/docker on Synology; find it on QNAP with ls -d /share/*/.qpkg/container-station/bin/docker).

The agent pulls its config, verifies the signature against the baked public key, and starts backing up on the interval. An unsigned, tampered, or wrong-key config is refused and nothing is uploaded. restart: unless-stopped brings it back after a NAS reboot.

6. Watch it from the control machine ​

You do not need to SSH in to see whether the fleet is healthy — every cycle writes a heartbeat to S3:

bash
cargoship fleet status  s3://my-bucket/nas          # age, health, config version
cargoship fleet monitor s3://my-bucket/nas --once   # alert on writers gone stale
cargoship dashboard     s3://my-bucket/nas          # TUI; press 6 for the Fleet tab

Both are read-only and need no agent credentials. On the NAS itself, docker logs -f cargoship-ghostship-1 is the local channel — the agent binds no port, so there is no health endpoint to curl.

7. Rehearse a restore before you trust it ​

A backup you have not restored is a hypothesis. Do this once, from the control machine, with a break-glass identity (bucket read + kms:Decrypt — not the writer's key):

bash
cargoship fleet status s3://my-bucket/nas --json   # find the writer's upload id
cargoship restore s3://my-bucket/writers/lab-nas-1/uploads/<upload-id> ./restore-test --all

Compare a few files against the NAS. Recovery never needs the original box, its writer id, or any local state.

8. Protect the history ​

The agent cannot delete, but a stolen control credential could. Enable S3 Versioning + Object Lock on the bucket and verify it:

bash
cargoship fleet lock-status s3://my-bucket/nas

See the retention and immutability section of ghost-ship for the rest, including why pruning runs from the control machine and why losing the fleet CMK loses the data.

Troubleshooting ​

SymptomCause & fix
docker: command not found over SSHContainer Station's docker is not on PATH; use the full path (ls -d /share/*/.qpkg/container-station/bin/docker).
exec format errorAn image built for the wrong arch. The published image is multi-arch — pull it rather than building locally, and don't pin an -amd64/-arm64 suffixed tag by hand.
exec: cargoship: not foundYou are running a locally built image that has no binary in it. Pull ghcr.io/scttfrdmn/cargoship:<version>.
S3 301 PermanentRedirectRegion mismatch. The emitted compose passes --region; make it the bucket's region.
Credentials mounted but "no credentials"Mounted at /root/.aws? The image runs as uid 65532 whose home is /home/cargoship — mount /home/cargoship/.aws/credentials.
Permission denied reading aws-credentialschmod 600 ~/cargoship/aws-credentials on the NAS.
Config refused: signatureThe uploaded config.yaml no longer matches config.yaml.sig. Re-sign after every edit and upload both.
Config refused: "widens scope"You added a watch path (or enabled source deletion). That needs allow_scope_expansion: true in the signed config — deliberately, so a config pull cannot silently broaden what the NAS reads.
Every cycle re-uploads everythingCheck delta_source in cargoship fleet status. none every cycle means the manifest cache is not persisting — most often the cargoship-state volume is missing from the compose file, so the cache is recreated empty on each restart. A manifest cache warning in the agent log names the reason. Also expected for one cycle a month, when the 30-day trust window expires.
fleet status shows nothingNo heartbeat yet (give it one interval), or you pointed it at the wrong base prefix — use the same s3://bucket/base you passed to init.
Writer shows config_error but stays healthyWorking as designed: a bad config refresh keeps the last-good config running. Fix and re-upload; the next cycle adopts it.
Looking for a metrics or health endpointThere isn't one. The agent is outbound-only; fleet status and docker logs are the status channels.

Security notes ​

  • The agent runs as non-root (uid 65532) and mounts your data read-only.
  • Its IAM identity is write-only and delete-free — no s3:DeleteObject, no access to other writers, and (with --write-only) no kms:Decrypt. A compromised NAS cannot destroy or read back your backups.
  • Credentials arrive as a mounted file, never environment variables, which would otherwise be visible in docker inspect and NAS UI panels.
  • No inbound port is opened or needed.
  • Decommission a writer with cargoship ghostship scuttle lab-nas-1 (add --purge-data s3://my-bucket/nas --yes to delete its backups too).

See also ​