# \[Tutorial\] Run your own satellite (part 17) - Placement

**URL:** <https://forum.storj.io/t/tutorial-run-your-own-satellite-part-17-placement/32108>\
**Category:** Developer Discussions\
**Tags:** satellite-operator, satellites\
**Created:** [July 30, 2026, 2:52pm UTC](https://forum.storj.io/t/tutorial-run-your-own-satellite-part-17-placement/32108 "2026-07-30T14:52:45Z")\
**Posts on this page:** 17\
**Page:** 1

<div class="post-metadata">

**Author:** ![kocoten1992](https://storj.bcdn.literatehosting.com/user_avatar/forum.storj.io/kocoten1992/32/7819_2.png) [@kocoten1992](https://forum.storj.io/u/kocoten1992)\
**Post date:** [July 30, 2026, 2:52pm UTC](https://forum.storj.io/t/tutorial-run-your-own-satellite-part-17-placement/32108/1 "2026-07-30T14:52:45Z")

</div>

This is an advance feature of satellite, the engine behind storage tier.

 ![image](https://storj-s3.bcdn.literatehosting.net/original/3X/c/2/c27ec41d793ad4dc03153faa0073f7757a4c13b5.jpeg)

You can have as many tier as you like (65536 limit for now) with extraordinary placement rule - the placement rule itself is a mini programming language.

# What is placement?

A placement is a named policy for bucket that answers four questions about a piece of data:

1. Which nodes are eligible to hold it? (filter)
2. How do we pick from among the eligible nodes? (selector)
3. What must remain true after repair? (invariant)
4. How many pieces do we make, and when do we repair? (EC overrides)

Every bucket on your satellite carries a placement ID. Placement 0 is the default and is used whenever nothing else is specified.

Have a look at the [legacy placement](https://github.com/storj/common/blob/ba1bff0a7846672907571d8063c0befc86d9e7b0/storj/placement.go#L16-L54).

To ensure maximum compatibility, start your own placement id at 11 or higher.

## Side story: config

Storj gave you many way to config your satellite:

1. ENV variable
2. binary runtime flags
3. config.yaml

My rule of thumb: use `config.yaml` for config that contain a long string or sensitive information or satellite specific content, use binary flags for everything else.

Never use ENV variable - it only suitable for an unforgiving env like container where it hard for you to debug on production (some team might disagree). One benefit of ENV variable is that it more secure - but it also hard for you to debug.

Put everything on a single config.yaml seem like good practice - until you realize, you don’t know which service actually use that config and feel the pain every time to read through the yaml file. Of course, the inverse is also true, now you have to copy the same config for 2 or more services - but hey, at least you know that service actually use it.

# Preparation

To use this feature you need to enable a few things:

`--overlay.geo-ip.db=/tmp/GeoLite2-City.mmdb` on `satellite-modular api`

`--console.placement.self-serve-enabled=true`: this one is nasty, you need to this on `satellite-modular console` BUT you also need this on `satellite-modular api` — If you want only admin can change bucket tier, then don’t enable this feature.

 ![image](https://storj-s3.bcdn.literatehosting.net/original/3X/f/6/f6b72ba2f92da06adbcc5340b1421f444829ae83.png)

## Side lore: delete doesn’t really mean delete

While researching this, I notice I don’t like few things when operate the satellite - delete doesn’t really mean delete.

1. Delete account mean mask your user record, email become `deactivated+%s@storj.io`
2. Delete your bucket and `value_attribution` is still there, I understand about calculating billing, paying for partner and SNO, but there are no way to delete those records.
3. Delete your project just mean it will become `status = 0`  
…

Maybe more, I didn’t check it all… To me, database is a sacred place: those records will not live there rent free forever.

# Example config for placement

This one should be on config.yaml:

```auto
console:
  placement:
    self-serve-details: |
      - id: 0
        id-name: "GLOBAL_0"
        name: "Global"
        title: "Globally Distributed"
        description: "The data is globally distributed."
      - id: 11
        id-name: "US_SELECT_11"
        name: "US Select 11"
        title: "US Select 11"
        description: "Store data only on Select nodes in the United States."
    allowed-placement-ids-for-new-projects: '[0]' # if only one choice, front end will remove choice UI for you
# maybe you don't need this
#payments:
# products: |
# - id: 1
# name: "Global"
# short-name: "GLOBAL_0"
# storage: "4"
# egress: "7"
# segment: "0.0000088"
# - id: 2
# name: "US Select 11"
# short-name: "US_SELECT_11"
# storage: "8"
# egress: "10"
# segment: "0.0000088"
# placement-price-overrides: |
# 1: [0]
# 2: [11]

# short form placement
placement: '0:annotation("location","GLOBAL_0");11:annotation("location","US_SELECT_11")'

# or long form placement: /etc/storj/placement.yaml
# placement: /etc/storj/placement.yaml

```

# What is possible with placement rule?

This entire section is out of my reach, Claud, can you take the mic? Sure thing:

```auto
# /etc/storj/placement.yaml
templates:
  SIGNER: 12Q8q2PofHPwycSwAVCpjNxxzWiDJhi8UV4ceZBo4hmNARpYcR7
  NO_DATACENTER: exclude(tag("$SIGNER","datacenter","true"))

placements:
  - id: 0
    name: global
    filter: $NO_DATACENTER
    selector: attribute("last_net")
    invariant: maxcontrol("last_net", 1)

  - id: 1
    name: eu-1
    filter: country("EU") && $NO_DATACENTER
    upload-filter: exclude(country("DE"))
    selector: attribute("last_net")
    invariant: maxcontrol("last_net", 1)
    download-selector: random
    ec:
      minimum: 29
      repair: "+5"
      success: 80
      total: 110

```

There are also `cohort` in placement I think?

To test placement, there is a tool in `cmd/tools/placement-test`.

* * *

- [nodeselection keyword (part 1)](https://forum.storj.io/t/tutorial-run-your-own-satellite-part-17-placement/32108/12#p-208652-example-2)
- [nodeselection keyword (part 2)](https://forum.storj.io/t/tutorial-run-your-own-satellite-part-17-placement/32108/13#p-208703-fromstringexpr-1)
- [nodeselection keyword (part 3)](https://forum.storj.io/t/tutorial-run-your-own-satellite-part-17-placement/32108/16)
- [filter vs upload-filter vs selector vs invariant vs download-selector vs cohort?](https://forum.storj.io/t/tutorial-run-your-own-satellite-part-17-placement/32108/17)

---

<div class="post-metadata">

**Author:** ![littleskunk](https://storj.bcdn.literatehosting.com/user_avatar/forum.storj.io/littleskunk/32/63_2.png) [@littleskunk](https://forum.storj.io/u/littleskunk)\
**Post date:** [July 30, 2026, 3:03pm UTC](https://forum.storj.io/t/tutorial-run-your-own-satellite-part-17-placement/32108/2 "2026-07-30T15:03:24Z")

</div>

> [@kocoten1992](#):
>
> This topic is a bit advance for me

You do want to enable choiceofn in your node selection. Huge performance boost.

For the cleanup of deleted accounts there is a job to enable. Its called pending deletion job. It should cleanup all the data but you are right about the remaining metadata. I would say that metadata can be cleaned up after a reasonable time. So there is room for extending the scope of the pending deletion job.

---

<div class="post-metadata">

**Author:** ![Toyoo](https://storj.bcdn.literatehosting.com/user_avatar/forum.storj.io/toyoo/32/1126_2.png) [@Toyoo](https://forum.storj.io/u/Toyoo)\
**Post date:** [July 30, 2026, 9:22pm UTC](https://forum.storj.io/t/tutorial-run-your-own-satellite-part-17-placement/32108/3 "2026-07-30T21:22:33Z")

</div>

Try asking Claude to generate you the weirdest possible, but still making sense placement rule. Curious what it will build.

---

<div class="post-metadata">

**Author:** ![kocoten1992](https://storj.bcdn.literatehosting.com/user_avatar/forum.storj.io/kocoten1992/32/7819_2.png) [@kocoten1992](https://forum.storj.io/u/kocoten1992)\
**Post date:** [July 30, 2026, 9:48pm UTC](https://forum.storj.io/t/tutorial-run-your-own-satellite-part-17-placement/32108/4 "2026-07-30T21:48:26Z")

</div>

First of, thanks @Th3Van for making this possible, truth be told, I always use Claude free before, but he gift me a subscription, still struggle to use it because I optimize my work flow with the free one.

And oh boy, this is what Claude produce:

```auto
Placement 42 — vespers

No two pieces of your object share a subnet, a power grid, or a sunrise.

```

The premise: the three things that actually kill erasure-coded durability are **correlated** failures, and Storj already gives you the machinery to declare arbitrary failure domains via signed node tags. So this placement treats three of them as first-class:

| domain | tag | why it’s real |
| --- | --- | --- |
| **subnet** | `last_net` (built in) | the classic one |
| **synchronous grid** | `grid` | Continental Europe is _one_ AC interconnection. A system-wide frequency collapse (cf. Iberia 2025) drops every node on it in the same second. Country borders don’t describe this — grid boundaries do. |
| **sunrise** | `tz` (UTC offset band) | consumer nodes have _correlated diurnal_ behaviour. Every node in UTC+01 hits evening prime-time congestion simultaneously. Spreading pieces across longitude means the object is never uniformly congested. |
| **plate** | `plate` | coarse, so it only appears as a ceiling in the invariant: never let one plate-boundary region hold enough pieces to matter. |

```auto
templates:
  SIGNER: 12Q8q2PofHPwycSwAVCpjNxxzWiDJhi8UV4ceZBo4hmNARpYcR7

  # --- attested failure domains ------------------------------------
  GRID: node_attribute("tag:$SIGNER/grid") # synchronous AC interconnection
  SUNRISE: node_attribute("tag:$SIGNER/tz") # "UTC+01", "UTC-05", ...
  SUBNET: node_attribute("last_net")

  ATTESTED: >-
    tag("$SIGNER","grid",notEmpty()) &&
    tag("$SIGNER","tz",notEmpty()) &&
    tag("$SIGNER","plate",notEmpty()) &&
    exclude(tag("$SIGNER","datacenter","true"))

  # sqrt of free bytes, hard-capped: 16 TB of headroom is worth exactly as
  # much as 160 TB. anti-whale. minus a penalty for what you already hoard.
  # NOTE the outer parens - see gotcha #2 below.
  FITNESS: >-
    (min((node_value("free_disk") ^ 0.5) / 40000.0, 100.0)
      - node_value("piece_count") / 2000000.0)

placements:
  - id: 42
    name: vespers

    filter: >-
      country("EU","EEA","US","CA","GB","CH","JP","AU","NZ","!RU","!BY","!NONE")
      && $ATTESTED

    # eligible for repair-placement, but no *new* uploads if nearly full
    # or if the failure tracker says you've been dropping uploads
    upload-filter: >-
      select("free_disk", ">", 5000000000)
      && successfulAtLeastPercent(uploadFailureTracker, 0.9)

    selector: |-
      multi(
        fixed(30, filter(tag("$SIGNER","class","hot"), stream(
          dropcof2(randomstream, 8, $FITNESS),
          streamfilter(groupconstraint($SUBNET, 1)),
          streamfilter(groupconstraint($GRID, 3)),
          streamfilter(groupconstraint($SUNRISE, 4)),
          choiceofns(3, $FITNESS)
        ))),
        fixed(60, filter(tag("$SIGNER","class","warm"), reduce(
          stream(
            randomstream,
            streamfilter(groupconstraint($SUBNET, 1)),
            streamfilter(groupconstraint($GRID, 6)),
            streamfilter(groupconstraint($SUNRISE, 8)),
            choiceofns(2, $FITNESS)
          ),
          $FITNESS * -1,
          atleast($SUBNET, daily(240, 120, 120, 240, 360, 300)),
          atleast($GRID, 10)
        ))),
        fixed(20, filter(tag("$SIGNER","class","cold"), stream(
          randomstream,
          streamfilter(groupconstraint($SUBNET, 1)),
          streamfilter(groupconstraint($GRID, 2)),
          choiceofns(2, node_value("free_disk") ^ 0.5)
        )))
      )

    invariant: >-
      maxcontrol("last_net", 1) &&
      maxcontrol("tag:$SIGNER/grid", 11) &&
      maxcontrol("tag:$SIGNER/plate", 44) &&
      filter(exclude(tag("$SIGNER","datacenter","true")))

    # the upload isn't finished until: 80 pieces are up AND, after deleting
    # every piece on the largest grid *and* every piece in the largest
    # sunrise band, at least 34 (= the repair threshold) still remain.
    cohort-requirements: >-
      min(80) &&
      withhold(attr("tag:$SIGNER/grid"), 1,
        withhold(attr("tag:$SIGNER/tz"), 1, min(34)))

    download-selector: best(uploadSuccessTracker)

    ec:
      minimum: 29
      repair: "+5"
      success: 80
      total: 110

```

**Worst case per grid** is 3 + 6 + 2 = 11 of 110 pieces. Lose an entire synchronous interconnection and you’re at 99, nowhere near the repair threshold of 34, and the repair worker never even wakes up. Same story for a sunrise band (16/110).

### Gotchas I found in the source while building this

These cost me more time than the design did, and they aren’t in the tutorial:

1. **Template expansion is a single pass over a Go map** (`LoadConfigFromString`), so it’s _iteration-order dependent_. `A → B → C` chains will silently expand inconsistently between restarts. Keep nesting to exactly one level — every template above only ever references `$SIGNER`.
2. **Wrap arithmetic templates in parentheses.** `$FITNESS * -1` where `FITNESS: a - b` textually becomes `a - b * -1` = `a + b`. Silent, wrong, and it flips your `reduce()` sort order.
3. **`reduce()` sorts ascending** (`Compare` returns `1` when score1 \> score2), so the _lowest_ scores land in the pool first. That’s why the sort key is `$FITNESS * -1`.
4. **`multi()` divides `n` by the number of selectors** unless each branch is wrapped in `fixed()`. Wrap them.
5. **`groupconstraint` state is per-substream.** Under `multi()` the limits _add up_ — three branches at 3/6/2 per grid means 11 per grid overall, not 6.
6. **Filter lines have no arithmetic.** `AddArithmetic` is only applied in `SelectorFromString`, so `node_value(...) / 2` works in `selector:` and blows up in `filter:`. Use `select("free_disk", ">", N)` there instead.
7. **`tag(..., notEmpty())` excludes nodes that lack the tag entirely** — `TagFilter.Match` returns false when no matching tag exists. That’s what makes `$ATTESTED` a hard “attested or you’re out” gate.
8. **`dropcof2` / `dropworst` take absolute node counts** and run at cache-refresh time, so those nodes are banned until the next refresh. On a small network `dropcof2(randomstream, 8, ...)` against a thin `hot` tier will hurt.

* * *

# Claude timeout, and there a button to continue, so I press

---

<div class="post-metadata">

**Author:** ![kocoten1992](https://storj.bcdn.literatehosting.com/user_avatar/forum.storj.io/kocoten1992/32/7819_2.png) [@kocoten1992](https://forum.storj.io/u/kocoten1992)\
**Post date:** [July 30, 2026, 9:50pm UTC](https://forum.storj.io/t/tutorial-run-your-own-satellite-part-17-placement/32108/5 "2026-07-30T21:50:54Z")

</div>

I finished the harness, and running it immediately paid for itself — **the config I posted last message doesn’t work on stock storj**. Two separate problems, both invisible at parse time.

### Bug 1: `choiceofns()` inside `stream()` panics at cache-refresh time

```auto
panic: unknown stream step type: func(nodeselection.NodeStream) nodeselection.NodeStream
        nodeselection.Stream.func1 stream.go:126

```

In `config.go` the mito binding is declared as:

go

```auto
"choiceofns": func(n int64, score any) func(NodeStream) NodeStream {
	return ChoiceOfNStream(n, score.(ScoreNode))
},

```

`ChoiceOfNStream` returns the _named_ type `StreamStep`, but the wrapper’s declared return type is the _unnamed_ `func(NodeStream) NodeStream`. So that’s the dynamic type mito boxes, and `Stream`’s type switch (`case StreamStep:` / `case StreamFilterInit:`) falls through to `default: panic(...)`.

It’s latent because `LoadConfigFromString` only builds the `NodeSelectorInit` — the panic fires later, when the node cache refreshes. And `config_test.yaml` id 14 is the only entry using `choiceofns`; `config_test.go` only ever calls `.Selector(...)` on placements 0, 2, 19, 21 and 22. So nothing covers it. One-line fix: change the wrapper’s return type to `StreamStep`.

### Bug 2: `node_value` arithmetic can’t reach `lastbut`/`median`/`desc`/`compare`

```auto
type mismatch: reflect: Call using nodeselection.NodeValue as type nodeselection.ScoreNode

```

`ConvertType` knows how to lift `NodeValue` → `ScoreNode`, but only the bindings that explicitly call `convertToCompareNodes`/`ConvertType` benefit (`choiceofn`, `choiceoftwo`, `dropworst`, `dropcof2`, `reduce`). `lastbut`, `median`, `desc` and `compare` take `ScoreNode` directly, so any arithmetic expression is rejected. In practice: you can only feed them `tracker`, `uploadSuccessTracker` or `piececount(n)`.

I also confirmed a third thing worth knowing: **`maxgroup()` has the sign backwards for the obvious use case.** `ChoiceOfNSelection` keeps the _higher_ score and `MaxGroup` returns the _size of the biggest group_ — so `choiceofnselection(3, ..., maxgroup("last_ip"))` picks the **most** clumped of the three candidate layouts. And since `ScoreSelection` isn’t covered by the arithmetic ops, you can’t negate it in config.

### The corrected config (this one actually runs)

```auto
templates:
  SIGNER: 12Q8q2PofHPwycSwAVCpjNxxzWiDJhi8UV4ceZBo4hmNARpYcR7

  GRID: node_attribute("tag:$SIGNER/grid") # synchronous AC interconnection
  SUNRISE: node_attribute("tag:$SIGNER/tz") # "UTC+01", "UTC-05", ...
  SUBNET: node_attribute("last_net")

  ATTESTED: >-
    tag("$SIGNER","grid",notEmpty()) &&
    tag("$SIGNER","tz",notEmpty()) &&
    tag("$SIGNER","plate",notEmpty()) &&
    exclude(tag("$SIGNER","datacenter","true"))

  FITNESS: >-
    (min((node_value("free_disk") ^ 0.5) / 40000.0, 100.0)
      - node_value("piece_count") / 2000000.0)

placements:
  - id: 42
    name: vespers

    filter: >-
      country("EU","EEA","US","CA","GB","CH","JP","AU","NZ","!RU","!BY","!NONE")
      && $ATTESTED

    upload-filter: >-
      select("free_disk", ">", 5000000000)
      && successfulAtLeastPercent(uploadFailureTracker, 0.9)

    selector: |-
      multi(
        fixed(30, filter(tag("$SIGNER","class","hot"), stream(
          dropcof2(randomstream, 8, $FITNESS),
          streamfilter(groupconstraint($SUBNET, 1)),
          streamfilter(groupconstraint($GRID, 3)),
          streamfilter(groupconstraint($SUNRISE, 4))
        ))),
        fixed(60, filter(tag("$SIGNER","class","warm"), choiceofnselection(3,
          reduce(
            stream(
              randomstream,
              streamfilter(groupconstraint($SUBNET, 1)),
              streamfilter(groupconstraint($GRID, 6)),
              streamfilter(groupconstraint($SUNRISE, 8))
            ),
            $FITNESS * -1,
            atleast($SUBNET, daily(240, 120, 120, 240, 360, 300)),
            atleast($GRID, 10)
          ),
          lastbut(desc(piececount(2000000)), 2)
        ))),
        fixed(20, filter(tag("$SIGNER","class","cold"), stream(
          dropcof2(randomstream, 8, node_value("free_disk") ^ 0.5),
          streamfilter(groupconstraint($SUBNET, 1)),
          streamfilter(groupconstraint($GRID, 2))
        )))
      )

    invariant: >-
      maxcontrol("last_net", 1) &&
      maxcontrol("tag:$SIGNER/grid", 11) &&
      maxcontrol("tag:$SIGNER/plate", 50) &&
      filter(exclude(tag("$SIGNER","datacenter","true")))

    cohort-requirements: >-
      min(80) &&
      withhold(attr("tag:$SIGNER/grid"), 1,
        withhold(attr("tag:$SIGNER/tz"), 1, min(34)))

    download-selector: best(uploadSuccessTracker)

    ec:
      minimum: 29
      repair: "+5"
      success: 80
      total: 110

```

The per-node choice-of-n moved out of the streams. The warm tier now runs the whole selection three times and keeps the layout whose third-most-loaded node is least loaded (`choiceofnselection` + `lastbut(desc(piececount(...)), 2)`) — which is the documented “doesn’t break the delegate’s pre-conditions” variant, unlike `choiceofn`, which over-selects and would have made the per-grid stream constraints infeasible.

### Measured

5,054 synthetic nodes across 14 real synchronous interconnections, 11 UTC bands, 8 plate regions; 6% unattested, 5% datacenter, plus RU/BY nodes. 4,437 pass `filter` + `upload-filter`. 2,000 selections at n=110:

```auto
selector errors: 0
pieces returned min=110 p50=110 max=110
distinct subnets min=110 p50=110 max=110
distinct grids min=13 p50=14 max=14
distinct sunrise bands min=10 p50=11 max=11

pieces on busiest grid min=11 p50=11 max=11
pieces on busiest sunrise min=13 p50=15 max=18
pieces on busiest plate min=34 p50=40 max=45

hot/warm/cold 30 / 60 / 20, every time
invariant flagged out-of-placement: 0

```

Blackout drill against the repair threshold of 34:

```auto
lose the busiest grid 99 survivors (worst case, every run)
lose the busiest sunrise 92 survivors (worst case)
lose both (cohort rule) 81 survivors (worst case)

```

So the cohort requirement `withhold(grid,1, withhold(tz,1, min(34)))` is satisfied with ~47 pieces of slack — you could lose the whole Continental European interconnection _and_ the entire UTC−05 evening at the same instant and repair still wouldn’t trigger.

### Two more things the simulator caught

**`groupconstraint` state is per-substream, and `multi()` breaks it.** My first run showed `distinct subnets min=104` and 1.4 pieces per object flagged by `maxcontrol("last_net", 1)` — the hot, warm and cold streams each keep their own buffer, so all three can pick the same /24. That’s a self-inflicted permanent repair churn: repair evacuates the duplicate, re-selection re-collides. It only goes to zero if class is declared **per subnet** rather than per node (which is what a real operator would do — one box, one class). If you can’t guarantee that, use `maxcontrol("last_net", 3)` instead, one per tier.

**Watch the plate ceiling.** At 44 the observed max was exactly 44 — one node’s worth of margin, because `ClumpingByAttribute` flags on `count >= maxAllowed`. Raised to 50. The number that actually matters is 110 − 34 = 76.

Happy to hand over the harness (`sim/` — copies of `nodeselection` with `metabase`/`pb`/`storj` stubbed, plus the generator) if anyone wants to run their own config through it before pointing a satellite at it.

---

<div class="post-metadata">

**Author:** ![Toyoo](https://storj.bcdn.literatehosting.com/user_avatar/forum.storj.io/toyoo/32/1126_2.png) [@Toyoo](https://forum.storj.io/u/Toyoo)\
**Post date:** [July 31, 2026, 1:58am UTC](https://forum.storj.io/t/tutorial-run-your-own-satellite-part-17-placement/32108/6 "2026-07-31T01:58:42Z")

</div>

> [@kocoten1992](#):
>
> picks the **most** clumped of the three candidate layouts

LOL. Might actually explain some of the anomalies I recall were discussed on the forum.

> [@kocoten1992](#):
>
> coarse, so it only appears as a ceiling in the invariant: never let one plate-boundary region hold enough pieces to matter.

This probably ties with [How big area would you need to EMP to kill Storj?](https://forum.storj.io/t/how-big-area-would-you-need-to-emp-to-kill-storj/19467) 🤣

---

<div class="post-metadata">

**Author:** ![arrogantrabbit](https://storj.bcdn.literatehosting.com/user_avatar/forum.storj.io/arrogantrabbit/32/11200_2.png) [@arrogantrabbit](https://forum.storj.io/u/arrogantrabbit)\
**Post date:** [September 6, 2026, 1:10am UTC](https://forum.storj.io/t/tutorial-run-your-own-satellite-part-17-placement/32108/7 "2026-09-06T01:10:21Z")

</div>

> [@kocoten1992](#):
>
> One benefit of ENV variable is that it more secure -

Less secure. Anyone who can inspect processes on the host will see the values.

---

<div class="post-metadata">

**Author:** ![Toyoo](https://storj.bcdn.literatehosting.com/user_avatar/forum.storj.io/toyoo/32/1126_2.png) [@Toyoo](https://forum.storj.io/u/Toyoo)\
**Post date:** [September 6, 2026, 1:14am UTC](https://forum.storj.io/t/tutorial-run-your-own-satellite-part-17-placement/32108/8 "2026-09-06T01:14:36Z")

</div>

> [@arrogantrabbit](#):
>
> Anyone who can inspect processes on the host will see the values

Any reasonable server OS limits this to the process’ user and root, so it follows exactly the OS security boundaries.

---

<div class="post-metadata">

**Author:** ![Vadim](https://storj.bcdn.literatehosting.com/letter_avatar/vadim/32/5_5575768a8748004e209b776fc1b2916d.png) [@Vadim](https://forum.storj.io/u/Vadim)\
**Post date:** [September 8, 2026, 8:16am UTC](https://forum.storj.io/t/tutorial-run-your-own-satellite-part-17-placement/32108/9 "2026-09-08T08:16:56Z")

</div>

![Screenshot 2026-09-08 111416](https://storj-s3.bcdn.literatehosting.net/original/3X/d/d/dd70e00d86304bf0e3f4eebb8fa2db4b1ab333e3.png)  
Here we see corelated nodes example, even it looks like different countries, in reality it look like some VPS gateway used.

---

<div class="post-metadata">

**Author:** ![flo](https://storj.bcdn.literatehosting.com/user_avatar/forum.storj.io/flo/32/64_2.png) [@flo](https://forum.storj.io/u/flo)\
**Post date:** [September 21, 2026, 12:45am UTC](https://forum.storj.io/t/tutorial-run-your-own-satellite-part-17-placement/32108/10 "2026-09-21T00:45:17Z")

</div>

> [@Vadim](#):
>
> in reality it look like some VPS gateway used.

Could as well be a routing issue of the ISP that conducts these checks.

---

<div class="post-metadata">

**Author:** ![kocoten1992](https://storj.bcdn.literatehosting.com/user_avatar/forum.storj.io/kocoten1992/32/7819_2.png) [@kocoten1992](https://forum.storj.io/u/kocoten1992)\
**Post date:** [September 30, 2026, 6:34am UTC](https://forum.storj.io/t/tutorial-run-your-own-satellite-part-17-placement/32108/11 "2026-09-30T06:34:59Z")

</div>

It is time to learn this seriously, one thing that trip me up while learning:

```auto
filters only mean to remove nodes, never added (except maybe `||` operator).

// Edit later
country("*") is also an added.

```

So if you don’t have any filter, that very close to `Global`.

# Continent

`continent()` keeps a short list of two-letter codes: `NA`, `SA`, `EU`, `AS`, `AF`, `OC` and `AN`.

It sit on `shared/location/continent.go`.

### Why not use GeoLite2-City.mmdb?

Probably because MaxMind’s database only maps a node’s IP to a country (see `overlay/service.go`), it never mentions continents.

### Example

```php
templates:
  SIGNER_ZERO: 1111111111111111111111111111111VyS547o
  NORMAL: exclude(tag("$SIGNER_ZERO","datacenter","true"))

placements:
  - id: 0
    name: global
    filter: $NORMAL
    invariant: maxcontrol("last_net",1)
    selector: unvetted(0.0, attribute("last_net"))

  # 1. Plain: one continent, keep the default spreading rules
  - id: 10
    name: south-america
    filter: continent("SA") && $NORMAL
    invariant: maxcontrol("last_net",1)
    selector: attribute("last_net")

  # 2. Union of continents (two calls joined with ||)
  - id: 11
    name: americas
    filter: (continent("NA") || continent("SA")) && $NORMAL
    invariant: maxcontrol("last_net",1)
    selector: attribute("last_net")

  # 3. Exclude a continent (nodes with unresolved country also pass)
  - id: 12
    name: not-africa
    filter: continent("!AF") && $NORMAL
    selector: attribute("last_net")

  # 4. Continent minus specific countries, using country() as a carve-out
  - id: 13
    name: europe-minus-russia
    filter: continent("EU") && country("*","!RU") && $NORMAL
    selector: attribute("last_net")

  # 5. Continent plus a tag: only nodes with a signer tag, in Asia
  - id: 14
    name: asia-tagged
    filter: continent("AS") && tag("$SIGNER_ZERO","tier","gold")
    selector: attribute("last_net")

  # 6. Different rules for new data vs existing data:
  # stored anywhere in Oceania, but never send new data to Australia
  - id: 15
    name: oceania-no-au-new
    filter: continent("OC") && $NORMAL
    upload-filter: exclude(country("AU"))
    selector: attribute("last_net")

```

Explain filter `id: 12`:

| Node | continent(“!AF”) | $NORMAL | Result |
| --- | --- | --- | --- |
| Home node in Germany | pass | pass | **eligible** |
| Home node in Kenya | fail | pass | excluded |
| Datacenter node in Germany | pass | fail | excluded |
| Datacenter node in Kenya | fail | fail | excluded |
| Node with unresolved country, no tag | pass | pass | **eligible** |

---

<div class="post-metadata">

**Author:** ![kocoten1992](https://storj.bcdn.literatehosting.com/user_avatar/forum.storj.io/kocoten1992/32/7819_2.png) [@kocoten1992](https://forum.storj.io/u/kocoten1992)\
**Post date:** [September 30, 2026, 12:11pm UTC](https://forum.storj.io/t/tutorial-run-your-own-satellite-part-17-placement/32108/12 "2026-09-30T12:11:06Z")

</div>

# Filter vs Attribute

What is filter and what is attribute?

| Kind | Type in code | Question it answers | Example |
| --- | --- | --- | --- |
| Filter | NodeFilter: node → bool | Is this node allowed? | continent(SA), country(DE) |
| Attribute | NodeAttribute: node → string | What label does this node carry? | last\_net, country, tag:…/dc |

`continent("SA")` is a filter. It builds a set of country codes from location. Continents and checks each node’s country against it.

`last_net` is an attribute. It returns a string, such as 203.0.113.0, and a string can’t answer allowed/not allowed. It only becomes useful when something else groups, compares or tests against it.

## Example

```auto
# example in satellite/nodeselection/config_test.yaml

- id: 3
  name: ifeqselector
  # attribute expressions can be used in both selector and invariant definitions
  invariant: maxcontrol(if(eq("tag:surge","true"),"last_ip_port","last_net"),1)
  selector: attribute(if(eq("tag:surge","true"),"last_ip_port","last_net"))

```

The example above do not have `filter`, so can I just copy `maxcontrol(if(eq("tag:surge","true"),"last_ip_port","last_net"),1)` and put into filter?

Turn out, there are limited amount of supported syntax for filters, [var supportedFilters](https://github.com/storj/storj/blob/0f5b83d58c78bd75b8c539c8285d623503b24c63/satellite/nodeselection/config.go#L288-L367).

Here are the complete (current) list keyword can be use:

```txt
country
continent
all
&&
||
tag
exclude
empty
notEmpty
nodelist
select
none
successfulAtLeastPercent

```

As for attribute, the keyword is [var supportedAttributes](https://github.com/storj/storj/blob/0f5b83d58c78bd75b8c539c8285d623503b24c63/satellite/nodeselection/config.go#L54-L100):

```txt
node_attribute
subnet
eq
if
group
same

```

## Gotchas:

So I notice some wierd behavior when trying to use filter, these are the same:

```auto
exclude(country("DE"))
country("*", "!DE")

```

The syntax below are legitimate and the same:

```auto
exclude(continent("AF"))
continent("!AF")

```

But there are no syntax for `continent("*", "!AF")`, so - there a slight different between syntax design between continent and country, when you design your placement, it is good to ask an AI to double check it.

P/s: if you wrote `country("!DE")`, it really mean: matches no countries at all (not just DE), because it would starts from an empty set.

---

<div class="post-metadata">

**Author:** ![elek](https://storj.bcdn.literatehosting.com/user_avatar/forum.storj.io/elek/32/9189_2.png) [@elek](https://forum.storj.io/u/elek)\
**Post date:** [September 30, 2026, 3:39pm UTC](https://forum.storj.io/t/tutorial-run-your-own-satellite-part-17-placement/32108/13 "2026-09-30T15:39:36Z")

</div>

I would phrase it in this way:

attributes and filters are two different level of abstractions.

- filters (and upload-filters) are expression to apply to nodes.
- attributes can be part of those expressions (with other elements). Attribute is just a node → string function. We also have node\_values (which is node → number function)

But attributes (and values) can be also used in other expressions (invariant, selector, …)

I strongly recommend to test placements locally, as it’s easy to shoot yourself on your own foot.

Examples from my shell history:

```auto
stbb placement nodes --placement=3 --satellite spiridon --placement-config=.../placement.yaml --selector last_ip

stbb placement select-pool --placement=3 --selector 'last_ip' --k 10000 --satellite spiridon --placement-config=.../placement.yaml

```

`--selector` is just the grouping for the output.

- The first command prints out the node pool.
- The second one simulates nodes selection (10000 times)

I test every single placement change first with these simulations.

stbb = [GitHub - elek/stbb · GitHub](http://github.com/elek/stbb)

---

<div class="post-metadata">

**Author:** ![kocoten1992](https://storj.bcdn.literatehosting.com/user_avatar/forum.storj.io/kocoten1992/32/7819_2.png) [@kocoten1992](https://forum.storj.io/u/kocoten1992)\
**Post date:** [October 2, 2026, 6:27am UTC](https://forum.storj.io/t/tutorial-run-your-own-satellite-part-17-placement/32108/14 "2026-10-02T06:27:37Z")

</div>

# FromString(expr)

Search this exact phrase in the source code: `FromString(expr string`

You’ll see 4 functions:

```js
func FilterFromString // use supportedFilters, see post above
func SelectorFromString // use both supportedFilters and supportedAttributes
func InvariantFromString // also use supportedFilters and supportedAttributes
func DownloadSelectorFromString // use supportedFilters

```

`Edit`: there another function: `func CohortRequirementsFromString`

```js
func CohortRequirementsFromString

```

## FilterFromString

Not much to see here, it use `supportedFilters` keywords, and that’s it.

## SelectorFromString

It use keywords from both `supportedFilters` and `supportedAttributes` and more:

```auto
attribute
random
unvetted
nodelist
filter
compare
choiceofn
choiceoftwo
pow2 // DEPRECATED
stream
choiceofns
groupconstraint
streamfilter
randomstream
dropworst
dropcof2
balanced
balancedf
weighted
weightedf
weighted_with_adjustment
topology
filterbest
bestofn
dual
choiceofnselection
lastbut
median
piececount
desc // DEPRECATED
node_value
maxgroup
atleast
reduce
daily
multi
fixed

```

## InvariantFromString

Also use keywords from both `supportedFilters` and `supportedAttributes` and more:

```txt
maxcontrol
filter

```

## DownloadSelectorFromString

Use keyword from `supportedFilters` and:

```txt
random
choiceofn
best
case
requestor
switch
filter

```

## CohortRequirementsFromString

Seem to just use these below:

```txt
attr
and
min
withhold

```

* * *

Gradually, we will research what each of the keyword mean and how to combine them together, the process is a bit slow at first, but hang on there.

---

<div class="post-metadata">

**Author:** ![kocoten1992](https://storj.bcdn.literatehosting.com/user_avatar/forum.storj.io/kocoten1992/32/7819_2.png) [@kocoten1992](https://forum.storj.io/u/kocoten1992)\
**Post date:** [October 7, 2026, 6:47am UTC](https://forum.storj.io/t/tutorial-run-your-own-satellite-part-17-placement/32108/15 "2026-10-07T06:47:39Z")

</div>

## Full example of a placement rule:

```auto
templates:
  SIGNER: 12Q8q2PofHPwycSwAVCpjNxxzWiDJhi8UV4ceZBo4hmNARpYcR7 # replace with YOUR signer ID
  NORMAL: exclude(tag("$SIGNER","datacenter","true"))

placements:
  - id: 20 # bucket placement ID (forum advice: use 11 or higher)
    name: eu-example # human-readable label

    # Which nodes may hold pieces of this placement at all
    filter: country("EU") && $NORMAL

    # Extra rule for NEW uploads only (existing pieces on Germany stay valid)
    upload-filter: exclude(country("DE"))

    # How to pick nodes from the allowed pool: one node per subnet
    selector: attribute("last_net")

    # What must stay true after repair: at most 1 piece per subnet
    invariant: maxcontrol("last_net", 1)

    # Which nodes serve downloads (note: it's download-selector, there is no "download-filter")
    download-selector: best(uploadSuccessTracker)

    # Upload counts as complete only if 80 pieces are stored, and 34 would still
    # remain after removing the largest "dc" group. (Meaning as described by the
    # forum author; I haven't read how cohort.go evaluates it.)
    cohort-requirements: min(80) && withhold(attr("tag:$SIGNER/dc"), 1, min(34))

    # Erasure coding overrides
    ec:
      minimum: 29 # k: pieces needed to rebuild a segment
      repair: "+5" # repair threshold = k + 5 = 34 (a plain int also works)
      success: 80 # upload is successful once this many pieces are stored
      total: 110 # how many pieces to create

```

## supportedFilters

```auto
templates:
  SIGNER: 12Q8q2PofHPwycSwAVCpjNxxzWiDJhi8UV4ceZBo4hmNARpYcR7 # a tag signer's node ID
  NORMAL: exclude(tag("$SIGNER","datacenter","true"))

placements:
  # ---- 1. country() ----------------------------------------------------
  - id: 101
    name: germany-only
    filter: country("DE")
    # Nodes with an unknown country are NOT matched (a set containing only DE).

  - id: 102
    name: eu-without-germany
    filter: country("EU","!DE")
    # "EU" = EU member states. Items apply left to right: add EU, then remove DE.

  - id: 103
    name: world-without-russia
    filter: country("*","!RU","!BY","!NONE")
    # "*" = everyone. "!NONE" also drops nodes whose country is unknown.
    # ORDER MATTERS: "*" resets the set to "everyone", so country("!RU","*") means everyone.
    # country("!RU") alone starts from an empty set, so it matches nothing.

  # ---- 2. continent() --------------------------------------------------
  - id: 104
    name: south-america
    filter: continent("SA")
    # Codes: NA SA EU AS AF OC AN. Exactly one argument, no "*" form.
    # continent("EU") is geographic Europe (includes RU, CH, GB), not the EU member list.
    # An unknown code like continent("Europe") panics in the code instead of returning a clean error.

  - id: 105
    name: not-africa
    filter: continent("!AF")
    # "!" starts from the full set and removes Africa, so unknown-country nodes still pass.

  # ---- 3. tag() / exclude() / empty() / notEmpty() ---------------------
  - id: 106
    name: only-gold-tagged
    filter: tag("$SIGNER","tier","gold")
    # The node must carry a tag signed by $SIGNER, named "tier", with the exact value "gold".

  - id: 107
    name: no-datacenter
    filter: exclude(tag("$SIGNER","datacenter","true"))
    # Nodes WITHOUT the tag pass (inner is false, so exclude gives true). Most nodes have no tag.

  - id: 108
    name: must-have-any-region-tag
    filter: tag("$SIGNER","region",notEmpty())
    # notEmpty() = the tag exists with a non-empty value. A node missing the tag is rejected.
    # empty() is the opposite trap: tag(...,"x",empty()) needs the tag present with an empty value.

  # ---- 4. && and || ----------------------------------------------------
  - id: 109
    name: eu-or-uk-no-dc
    filter: (country("EU") || country("GB")) && $NORMAL
    # Parenthesize with && and || mixes. country("EU","GB") says the same thing in one call, which is simpler.

  # ---- 5. select() -----------------------------------------------------
  - id: 110
    name: vetted-with-space
    filter: select("vetted","==","true") && select("free_disk",">",5000000000)
    # select(attribute_or_value, operator, value). Operators: = == != <> < <= > >=
    # Attributes are strings ("vetted","country","last_net","tag:..."); values are numbers ("free_disk","piece_count").
    # Anything else gives "Unsupported node attribute".

  # ---- 6. nodelist() / none() / all() ----------------------------------
  - id: 111
    name: my-own-nodes
    filter: nodelist("/etc/storj/allowed-nodes.txt")
    # One node ID per line (base58 or hex), "#" comments allowed.
    # The file is read once when the config loads, so editing it needs a restart.
    # See example of allowed-nodes.txt below

  - id: 112
    name: nothing-allowed
    filter: none()
    # Matches no nodes. Handy for temporarily disabling a placement.

  # ---- 7. upload-filter vs filter --------------------------------------
  - id: 113
    name: eu-stored-but-no-new-DE
    filter: country("EU")
    upload-filter: exclude(country("DE"))
    # filter = nodes allowed to hold pieces. upload-filter = extra rule for NEW uploads only.
    # Existing German pieces are still fine and aren't repaired away.
    # exclude(country("DE")) lets unknown-country nodes through, but filter already blocks them.

  # ---- 8. successfulAtLeastPercent() -----------------------------------
  - id: 114
    name: skip-flaky-nodes-for-uploads
    filter: country("EU")
    upload-filter: successfulAtLeastPercent(uploadFailureTracker, 0.9)
    # uploadFailureTracker is injected by the environment, not YAML.
    # Nodes with no data yet (NaN) pass; nodes with success rate >= 0.9 pass.

```

## Example content of `allowed-nodes.txt`:

```auto
# my friends' nodes (comment lines start with #)
1aNZuRaYRSxJAGZMBrikdvqNEE6K9BK82DmZnTv6mTqiW5M4W4

# the same kind of ID written as 64 hex characters also works
aaf88a377f642753ac823a0945a40d53c5c5997209b9d9bc47d418c5727e8000

# blank lines are fine, and so is indentation or trailing whitespace
   1aNZuRaYRSxJAGZMBrikdvqNEE6K9BK82DmZnTv6mTqiW5M4W4
```

---

<div class="post-metadata">

**Author:** ![kocoten1992](https://storj.bcdn.literatehosting.com/user_avatar/forum.storj.io/kocoten1992/32/7819_2.png) [@kocoten1992](https://forum.storj.io/u/kocoten1992)\
**Post date:** [October 7, 2026, 10:10am UTC](https://forum.storj.io/t/tutorial-run-your-own-satellite-part-17-placement/32108/16 "2026-10-07T10:10:45Z")

</div>

We have some new keywords:

## CreateNodeValue

`https://github.com/storj/storj/blob/45bf46c6bb0eef7dc7bc54f40a91a5be0c479463/satellite/nodeselection/node.go#L177`

```js
free_disk
piece_count

```

## CreateNodeAttribute

`https://github.com/storj/storj/blob/45bf46c6bb0eef7dc7bc54f40a91a5be0c479463/satellite/nodeselection/node.go#L224`

```js
last_net
id // or node_id - same thing
last_ip_port
last_ip
wallet
email
country
vetted

```

---

<div class="post-metadata">

**Author:** ![kocoten1992](https://storj.bcdn.literatehosting.com/user_avatar/forum.storj.io/kocoten1992/32/7819_2.png) [@kocoten1992](https://forum.storj.io/u/kocoten1992)\
**Post date:** [October 7, 2026, 11:24am UTC](https://forum.storj.io/t/tutorial-run-your-own-satellite-part-17-placement/32108/17 "2026-10-07T11:24:24Z")

</div>

# What is filter vs upload-filter vs selector vs invariant vs download-selector vs cohort-requirements

| Field | Question it answers | Phase | Affects pieces already stored? |
| --- | --- | --- | --- |
| filter | Which nodes may belong to this placement at all? | Upload, repair, download | Yes. Pieces on non-matching nodes are out of placement and get repaired. |
| upload-filter | Which of those may receive new data? | Upload only | No. Repair won’t move pieces because of it. |
| selector | Of the eligible nodes, which do I pick (and how spread out)? | Upload (and repair/balancer when choosing replacement nodes) | No, it only picks new nodes. |
| invariant | Is this segment’s current set of nodes well spread? | Repair | Yes. It flags clumped pieces as unhealthy. |
| download-selector | Of the nodes holding pieces, which do I hand to the client? | Download | No. |
| cohort-requirements | When does the uplink consider an upload successful enough? | Upload, enforced by the client | No. |

# Which field shows up where

| Field | Upload | Download | Repair |
| --- | --- | --- | --- |
| `filter` | Yes, builds the pool | Yes, narrows the nodes | Yes, marks out-of-placement |
| `upload-filter` | Yes, adds to the pool | No | No |
| `selector` | Yes, draws nodes | No | Probably, for replacements |
| `invariant` | No | No | Yes, flags clumped pieces |
| `download-selector` | No | Yes | No |
| `cohort-requirements` | Sent to the client, checked there | No | No |

# The order how fields is used:

`upload`: `filter` + `upload-filter`, then `selector`.

`download`: `filter`, then `download-selector`.

`repair`: `invariant` and `filter` used, `selector` only comes back into play when repair needs replacement nodes.

# selector vs invariant

Used at different times: selector prevents bad spread when you write, and invariant detects bad spread afterward. Nothing links them.
