# Pier — full documentation dump > This file is a plain-text concatenation of every documentation page, intended for LLM ingestion. > Authoritative source: https://pier.team/docs ## About Pier Pier is an open-source self-hosted PaaS. Single Rust binary, 20–40 MB RAM idle, 35+ one-click templates, alerts & notifications, multi-server agents, load balancing, private registry auth, scheduled S3 backups and automatic SSL. Founded 2026 by Aleksandr Litvinenko. Repository: https://github.com/joveptesg/pier. Current version: 1.0.0. ## Feature landing pages - Alerts & notifications: https://pier.team/features/alerts - Multi-server agents: https://pier.team/features/multi-server - Scheduled DB & volume backups: https://pier.team/features/backups - Load balancing with replicas: https://pier.team/features/load-balancing ## Catalogs - Service templates: https://pier.team/services (35+ official templates) - Changelog: https://pier.team/changelog --- ## 404: Not found > The page you're looking for doesn't exist in the docs. Source: https://pier.team/docs/404 This doc page doesn't exist. Try the [introduction](/docs/getting-started/introduction), or pick a section from the sidebar. --- ## From Docker Compose > Deploy a Docker Compose stack with one click. Source: https://pier.team/docs/applications/from-compose ## The flow 1. **Add service → From Compose**. 2. Paste or upload your `docker-compose.yml`. 3. Pier inspects the file and: - Injects `pier-net` as the default network (replacing any `network_mode: bridge` or unnamed network). - Strips host-port bindings (`ports:` sections). Traffic reaches containers through Traefik only. - Allocates internal ports for each service that exposes one. 4. Click **Deploy**. Pier runs `docker compose up` in its own working directory and streams the logs to the UI in real time. ## What this means for your Compose file You write Compose as if for a developer laptop. Pier handles the production wiring: - **No `ports:` mappings** — remove them or let Pier strip them; everything is on `pier-net`. - **No external networks** — Pier will override them. If you truly need a second network, ask on GitHub. - **No volume conflicts** — each stack gets prefixed volume names. ## Compose stack vs multi-service project A Compose stack is **one service** in Pier — you redeploy the whole stack at once. If you want to deploy and scale services independently, create them as separate Pier services and connect them via `pier-net` DNS names. --- ## From a Docker image > Deploy an existing Docker image with no build step. Source: https://pier.team/docs/applications/from-docker-image The simplest workflow. Use this for prebuilt images from Docker Hub, GHCR, or a private registry. 1. **Add service → From Docker image**. 2. Enter the image tag — for example `nginx:alpine` or `ghcr.io/owner/repo:v1.2.3`. 3. Set a service name. 4. Add env vars, volumes, and a domain if needed. 5. Click **Deploy**. Pier pulls the image, creates the container on `pier-net`, and attaches Traefik routes for each configured domain. ## Private registries For private GHCR/Docker Hub images, add credentials under **Settings → Registries**. Pier stores the login and authenticates `docker pull` on your behalf. --- ## From a Dockerfile > Deploy an app by pointing Pier at a Dockerfile. Source: https://pier.team/docs/applications/from-dockerfile Pier can build from a Dockerfile located in a Git repository or uploaded directly. ## From Git 1. In the dashboard, **Add service → From Git**. 2. Enter the repository URL (HTTPS or SSH). 3. Pick the branch, and the path to the Dockerfile (defaults to `./Dockerfile`). 4. Add build args and env vars as needed. 5. Click **Deploy** — Pier clones, builds, and starts the container. ## Auto-deploy on push Add a webhook in your GitHub/GitLab settings pointing to `https:///webhooks/github/`. Pier validates the signature, pulls the latest commit, rebuilds, and redeploys. Deployment history is kept — rollback one release with a single click. --- ## From Git > Continuous deployment from GitHub or GitLab. Source: https://pier.team/docs/applications/from-git ## Public repositories 1. **Add service → From Git → Public repository**. 2. Paste the repo URL. 3. Pick a branch, build strategy (Dockerfile / Compose / image tag), and click **Deploy**. Pier clones with `gix`, builds, and starts the service. ## Private repositories Two options: ### Deploy key 1. Pier generates a throwaway SSH keypair for the service. 2. Copy the public key to your repo's **Deploy keys** settings. 3. Pier uses the private key to clone. ### GitHub App For GitHub-only workflows, install the Pier GitHub App once. Pier then has read access to all connected repos without per-service key management. ## Webhooks After the first deploy, Pier shows a webhook URL. Add it to your repo's webhook settings. On push, Pier verifies the signature, pulls, rebuilds, and redeploys — with a deployment entry in the service history. Failed deploys do not replace the running container. The previous version stays up while you investigate the failure. --- ## Auto-build (Railpack) > Deploy from source without writing a Dockerfile. Railpack detects your language and builds an optimized image automatically. Source: https://pier.team/docs/applications/from-railpack **Auto-build (Railpack)** lets Pier compile your project straight from a Git repository — no Dockerfile, no buildpack config, no language-specific scaffolding. Pier shells out to the [Railpack](https://github.com/railwayapp/railpack) CLI (Railway's open-source builder and the active successor to Nixpacks), which talks to a [moby/buildkit](https://github.com/moby/buildkit) daemon and produces an OCI image Pier then runs like any other service. ## Before you enable it :::caution[Server requirements] Auto-build is substantially heavier than the other deploy paths. Compiling user code on the host has a different resource profile to running a pre-built container: | | Dockerfile / Compose / Docker Image | Auto-build (Railpack) | |---|---|---| | Minimum RAM | 512 MB | **4 GB** (8 GB for Rust) | | Free disk | a few GB per stack | **40+ GB** (BuildKit cache) | | First deploy | seconds | 1–10 minutes | If your VPS has less than 4 GB RAM, use the Dockerfile or Docker Image source instead — the create-resource form will show a hard warning otherwise, and the build will most likely OOM-kill itself or another process on the host. ::: ## Supported languages Railpack auto-detects the project type from common marker files. Zero configuration is needed for any of these: | Language / framework | Detected from | |---|---| | Node.js / Bun / Deno | `package.json`, `bun.lockb`, `deno.json` | | Python | `requirements.txt`, `pyproject.toml`, `Pipfile` | | Go | `go.mod` | | Rust | `Cargo.toml` | | PHP | `composer.json` | | Java | `pom.xml`, `build.gradle` | | Ruby | `Gemfile` | | Elixir | `mix.exs` | | Vite / Astro / Create React App | bundler config + build output directory | ## How it works under the hood 1. **Clone** — Pier clones the configured branch into a temp directory using the same `git` machinery as other Git sources (public, deploy-key or GitHub App). 2. **Detect + plan** — `railpack build` analyses the source tree and produces a [BuildKit LLB](https://github.com/moby/buildkit#exploring-llb) graph. Unlike layered Dockerfiles, the graph is fully parallel — independent steps run concurrently, and caching is content-addressable rather than line-by-line. 3. **Build** — BuildKit executes the graph against the daemon container started by `install.sh`, producing an OCI image tagged `pier-railpack/:`. 4. **Run** — Pier synthesises a small `docker-compose.yml` pointing at the new image and hands it to the same `docker compose up` path used by Dockerfile builds. Traefik labels, auto-SSL, port allocation and stack networking are all reused — only the *build* step is new. ## Custom configuration For projects that need overrides, drop a `railpack.json` at the repo root — Railpack picks it up automatically and Pier doesn't need to know about it. Example: ```json { "providers": ["node"], "buildAptPackages": ["libpq-dev"], "deploy": { "startCommand": "node dist/server.js" } } ``` The full reference lives at [railpack.com/configuration/file](https://railpack.com/configuration/file). ## Tuning knobs Three environment variables (set in the systemd unit or before `install.sh`) and one UI tab control Auto-build behaviour: - `PIER_RAILPACK_MAX_PARALLEL_BUILDS=N` — cap concurrent builds (default 1). Also editable from **Settings → Auto-build (Railpack)** in the dashboard. - `PIER_BUILDKIT_MEMORY=4g` — RAM limit for the moby/buildkit container (default 4g). - `PIER_SKIP_RAILPACK=1` — skip provisioning entirely. The card stays in the UI but build attempts will surface a clear "railpack binary not found" message. ## Troubleshooting **`railpack binary not found in PATH`** The host was installed with `PIER_SKIP_RAILPACK=1`, or `install.sh` couldn't download the release binary. Re-run `install.sh` without the skip flag, or install the binary manually from [github.com/railwayapp/railpack/releases](https://github.com/railwayapp/railpack/releases) to `/usr/local/bin/railpack`. **`build failed (exit 137)` — OOM kill** Your build exceeded available RAM. Either upgrade the VPS to 4 GB+ (8 GB for Rust), or constrain BuildKit with a lower `PIER_BUILDKIT_MEMORY` to fail-fast rather than starve neighbour processes. The Pier UI surfaces this state with a red banner when the host has less than 4 GB. **Builds stuck queued** Pier caps parallel builds at 1 by default to protect the host. If multiple users deploy at once, the others wait. Raise the cap in **Settings → Auto-build (Railpack)** if your host has spare RAM. **`Could not detect language`** Railpack didn't find a marker file it recognises. Either add the expected lockfile (e.g. `package-lock.json` for npm projects), drop a `railpack.json` declaring `providers`, or fall back to the Dockerfile source. ## Related - [From a Dockerfile](/docs/applications/from-dockerfile) — full control over the build, at the cost of writing one - [From a Docker image](/docs/applications/from-docker-image) — deploy a pre-built image from any registry, no host build at all - [From Docker Compose](/docs/applications/from-compose) — multi-service stacks --- ## Architecture > How Pier is structured internally, and why. Source: https://pier.team/docs/concepts/architecture ## One process, one binary ``` ┌──────────────────────────────────┐ │ Pier (single binary) │ │ │ Browser ───────▶ │ Axum ──▶ API routes (100+) │ │ │ │ │ ├──▶ MiniJinja ──▶ HTML (HTMX) │ │ ├──▶ Bollard ──▶ Docker Engine │ │ ├──▶ rusqlite ──▶ SQLite │ │ └──▶ reqwest ──▶ Remote Agents │ └──────────────────────────────────┘ │ ┌───────────────┴────────────────┐ │ Traefik (reverse proxy) │ │ Let's Encrypt · Auto-routing │ └────────────────────────────────┘ ``` ## Components | Layer | Crate | Role | |---|---|---| | HTTP + WebSocket | [axum](https://github.com/tokio-rs/axum) | API endpoints and SSE/WebSocket streams for logs & metrics | | Docker | [bollard](https://github.com/fussybeaver/bollard) | All container, volume, and network operations | | Database | [rusqlite](https://github.com/rusqlite/rusqlite) | Embedded SQLite, WAL mode for concurrent reads | | Templates | [minijinja](https://github.com/mitsuhiko/minijinja) | Server-rendered HTML for the dashboard | | Auth | `axum-login` + `totp-rs` | Opaque cookie sessions; TOTP 2FA with recovery codes | | SSH | [russh](https://crates.io/crates/russh) | Remote server provisioning | | Git | [gix](https://crates.io/crates/gix) | Clone and pull for Git-to-deploy | | Metrics | [sysinfo](https://crates.io/crates/sysinfo) | System and container metrics | | Cache | [moka](https://crates.io/crates/moka) | In-memory cache for Docker stats | | Runtime | [tokio](https://tokio.rs) | Async I/O | ## Dashboard The UI is server-rendered with MiniJinja. HTMX handles partial updates; Alpine.js covers client-side state for small interactions (modals, toggles). Total client-side JS is around **30 KB**. Everything ships embedded in the binary — no CDN, no external fonts, no bundler at runtime. ## Storage layout ``` /opt/pier/ ├── bin/pier # the binary ├── bin/pier.old # previous version (for rollback) ├── .env # PIER_SECRET — AES-256 key for env vars └── data/ ├── pier.db # SQLite main database ├── pier.db-wal # WAL file ├── backups/ # automated DB + .env backups └── traefik/ # dynamic config files per domain ``` ## Security at rest Every environment variable stored in the database is encrypted with **AES-256-GCM**. The key comes from `PIER_SECRET` (32 random bytes, base64-encoded) — systemd reads it from `/opt/pier/.env`, and if the variable is unset Pier persists a `.pier-secret` file in the data directory instead. The database alone is useless without the key — the split is intentional so that a leaked SQL dump does not leak secrets. Encryption is backward-compatible: rows written before the feature shipped are readable as plain text; new writes carry an `ENC:` prefix and are always encrypted. --- ## Domains & public ports > Assign a custom domain to a service with an automatic Let's Encrypt certificate, or expose it on a direct public port instead. Source: https://pier.team/docs/concepts/domains A domain attaches a hostname to one of your services. Pier turns that into a Traefik router and asks Let's Encrypt for a certificate — building directly on the edge proxy described in [Proxy and SSL](/docs/concepts/proxy-and-ssl), so you never touch Traefik config by hand. ## The domain lifecycle Adding a domain is a three-step flow: **add → point DNS → activate**. 1. **Add.** Under a service, open **Domains → Add** and enter the hostname (for example `app.example.com`). Pier stores the domain as a draft — `is_active = 0`. No Traefik router is written and no certificate is requested yet, so configuring a domain never burns a Let's Encrypt rate-limit slot prematurely. 2. **Point DNS.** Create an `A` (or `AAAA`) record for the hostname pointing at your server's public IP. Let's Encrypt validates over HTTP on port 80, so the record must resolve before activation succeeds. 3. **Activate.** Flip the activate toggle. Pier marks the domain active, writes the dynamic-config file, and Traefik picks it up within a second or two and requests the certificate. > Auto-generated `sslip.io` domains (created when a service is first deployed with the proxy enabled) skip this flow — they come up active immediately. ## Status values Each domain carries an `ssl_status` reflecting its certificate state. An SSL monitor reconciles it against Traefik's `acme.json`: | Status | Meaning | | --- | --- | | `provisioning` | Just activated; waiting for Traefik to obtain the certificate. | | `pending` | Awaiting certificate issuance. | | `active` | Certificate issued and serving over HTTPS. | | `expiring` | Certificate valid but expires within 7 days. | | `expired` | Certificate is past its expiry date. | Whether the domain is live is a separate `is_active` flag: a draft or deactivated domain has no Traefik router regardless of its SSL status. ## Deactivating and removing **Deactivate** (`POST /api/v1/domains/{id}/deactivate`) pulls the domain out of Traefik's live config — routing stops within seconds. The database row stays and the issued certificate remains in `acme.json`, so re-activating later is instant. **Activate** (`POST /api/v1/domains/{id}/activate`) is the inverse and is idempotent: calling it on an already-active domain just re-emits the config. To purge a domain entirely, use **delete** (`DELETE /api/v1/domains/{id}`). This removes the row and regenerates the service's Traefik config without it. ## Public ports as an alternative A domain gives you HTTPS at a hostname. When you instead want a raw `host:port` — a database admin tool, a game server, a TCP service — expose a **public port** rather than a domain. Toggle it under the service's ports (`PUT /api/v1/resources/{id}/port-public`). Pier publishes the chosen port as a direct Docker bind (`0.0.0.0:port`) on the service container itself — it does **not** route through Traefik. By default the public port matches the internal port Pier allocated; you can override it explicitly. > Public ports bypass Traefik entirely, so there is no automatic TLS. Use a domain when you need HTTPS termination; use a public port for non-HTTP protocols or direct access. See [Ports and allocation](/docs/concepts/ports) for how ports are assigned. ## Next steps - [Proxy and SSL](/docs/concepts/proxy-and-ssl) - [Ports and allocation](/docs/concepts/ports) - [Networking](/docs/concepts/networking) --- ## Networking > The pier-net Docker network and how services reach each other. Source: https://pier.team/docs/concepts/networking ## pier-net Pier creates a single Docker bridge network called `pier-net` and attaches every managed container to it. Inside the network, services reach each other by container name (Docker's built-in DNS). So if you have two services — `app` and `pier-db-postgres` — your app can connect with: ``` postgres://postgres:password@pier-db-postgres:5432/app ``` No port binding needed on the host. ## Why not bind ports? Pier does **not** expose container ports on the host for managed services. That avoids: - Port conflicts across services. - Unnecessary exposure on the host firewall. - Needing to run Traefik at the same layer. The only process that binds host ports 80 and 443 is Traefik itself. Everything else is internal. ## Internal URLs The Pier dashboard shows an **Internal Network** panel per service listing the `container_name:port` that other services in the project can use. Copy that directly into your application's environment variables. ## Reaching the outside For outbound traffic from a container, Docker handles NAT as usual — no changes required. Pier does not introduce any egress restrictions. --- ## Ports and allocation > How Pier allocates internal ports and avoids conflicts. Source: https://pier.team/docs/concepts/ports ## The allocation pool Pier reserves a configurable range — default `10000–65000`, overridable with `PIER_PORT_RANGE_START` and `PIER_PORT_RANGE_END` — for internal service ports. When you deploy a service that expects a port, Pier picks the next free one from this pool and stores it in the database. Port 80 and 443 belong to Traefik. Port 8443 (default) is the Pier dashboard. Because managed containers never bind host ports (see [Networking](/docs/concepts/networking)), the allocation pool is only used for Traefik → container routing. ## What this gives you - No port clashes between services — even with 50 services on one host, every internal port is unique. - No need to remember port numbers — Pier shows the allocated port in the service Overview. - The same Compose stack can be deployed multiple times under different names. --- ## Projects & services > How Pier groups your workloads. Source: https://pier.team/docs/concepts/projects-and-services ## Projects A **project** is a namespace. You put related services (a database, an app, a cache, a queue) into one project and manage them together. Projects show up as groups in the Canvas view and as a filter in the sidebar. ## Services A **service** is one managed container (or one Docker Compose stack) that Pier supervises. Each service has: - A **name** — user-visible and unique within the project. - A **template** — the source of truth for image, ports, volumes, env vars, and healthcheck. One of `database/*`, `service/*`, `application/*`, or a Git source. - A set of **env vars** — encrypted at rest. - A list of **domains** — each one gets a Traefik router and a Let's Encrypt certificate. - A **deployment history** — with one-click rollback to previous versions. ## Compose stacks If you deploy from Docker Compose, Pier treats the whole stack as one service. Individual containers in the stack are visible and streamable for logs, but they are managed as a unit — you redeploy the whole stack, not one container. --- ## Proxy and SSL > How Traefik and Let's Encrypt provide automatic HTTPS in Pier. Source: https://pier.team/docs/concepts/proxy-and-ssl ## Traefik as the edge Pier ships with a managed Traefik container that: - Watches `/opt/pier/data/traefik/dynamic/*.yml` for config changes. - Terminates TLS on ports 80 and 443. - Requests Let's Encrypt certificates for every configured domain. - Forwards traffic to the matching container on `pier-net`. ## Adding a domain 1. Under a service, click **Domains → Add**. 2. Enter the hostname — for example `app.example.com`. 3. Point DNS at your server's IP (A or AAAA record). 4. Pier writes a dynamic-config file describing the router, service, and cert resolver. Traefik picks it up within a second or two, requests a certificate, and starts serving the domain. ## Path-prefixed routes If you want one backend serving `example.com/v1`, Pier auto-adds a `StripPrefix` middleware so the backend receives `/health` rather than `/v1/health`. You configure the prefix once; no backend changes needed. ## Custom certificates Pier uses Let's Encrypt by default. If you already have certificates from another CA, drop them into `/opt/pier/data/traefik/certs/` and reference them in the domain config. Let's Encrypt is the default and the most common path. --- ## Browsing your data > Explore schemas, tables, structure and rows for SQL engines, collections and documents for MongoDB, and keys for Redis — all read-only and gated by the Viewer role. Source: https://pier.team/docs/data-editor/browsing Browsing is **read-only** and requires the `Viewer` role on the resource. It never mutates data — it only issues catalog and `SELECT`/`find`/`SCAN` reads. ## SQL engines (PostgreSQL, MySQL/MariaDB) ### Database & schema tree The browser first lists the **databases** the credentials can see, then the **schemas** and **tables/views** inside the selected one. Views are marked separately from tables. For MySQL/MariaDB — where a database *is* a schema — the tree is scoped to the connected database. Pick a different database from the selector at the top of the panel to re-scope the tree. ### Table structure Select a table to see its **structure**, read from `information_schema` (and `pg_indexes` / `information_schema.statistics` for indexes): - **Columns** — name, data type, nullability, and default value. - **Primary keys** — PK columns are flagged. - **Indexes** — index names with their definition (Postgres) or column list (MySQL). ### Rows The **Data** view pages through the table's rows. Pagination defaults to **50 rows per page** (configurable from 1 to 200) and shows a **total row count** so you know how far the table goes. Every value is rendered as text, so any column type displays cleanly in the grid. Column names always come from the catalog, and the `(schema, table)` pair is verified to exist before any query is built — so browsing can't be used to inject arbitrary identifiers. ## MongoDB The MongoDB browser walks the same tree shape, one level deeper for documents: - **Databases** — from `listDatabases`. - **Collections** — from `getCollectionNames()` on the selected database. - **Documents** — `find().skip().limit()` with a default page of **20 documents** (1–100), plus a `countDocuments` total. Documents are rendered as **EJSON**, so MongoDB types (`ObjectId`, `Date`, `Decimal128`, …) survive the round-trip instead of being flattened to plain JSON. ## Redis / Valkey Redis browsing is key-oriented: - **Key list** — built with `SCAN` (cursor-based, 200 keys per batch) and an optional `MATCH` pattern (defaults to `*`). Use **Load more** to advance the cursor. - **Per-key type** — each key shows its type (`string`, `list`, `set`, `zset`, `hash`, `stream`). - **Value view** — selecting a key fetches its value with the right command for its type: - `string` → `GET` - `list` → `LRANGE 0 500` - `set` → `SMEMBERS` - `zset` → `ZRANGE 0 500 WITHSCORES` - `hash` → `HGETALL` - `stream` → `XRANGE - + COUNT 100` - **TTL** — the key's remaining time-to-live is shown alongside the value. - **Database index** — switch between Redis logical databases **0–15** from the selector. ## Next steps - [SQL Runner](/docs/data-editor/sql-runner) — go beyond browsing and run your own statements. - [MongoDB & Redis](/docs/data-editor/nosql) — the Mongo Shell and Redis command box. --- ## MongoDB & Redis > Run mongosh scripts against MongoDB and raw commands against Redis/Valkey from the Pier dashboard — both audited, both gated by the Editor role. Source: https://pier.team/docs/data-editor/nosql The non-SQL engines get their own runners, shaped to their data model rather than forced into a table grid. Browsing both is covered in [Browsing your data](/docs/data-editor/browsing); this page is about running commands. Both runners require the **`Editor`** role and are recorded in the `db_query_log` audit table. ## MongoDB — the Mongo Shell The **Mongo Shell** runs an arbitrary `mongosh` script against the selected database. It's a collapsible panel below the collection browser, closed by default. - Pier prefixes your script with `db = db.getSiblingDB("")`, so `db` refers to the database you picked — then runs the rest verbatim. - Scripts execute through `mongosh` **inside the service container** (via docker-exec), authenticating as the root user from the service's encrypted env. There's no native Mongo driver in the Pier binary. - The shell's textual output is returned as-is, so `print(...)`, `printjson(...)` and the implicit result of an expression all show up. Example scripts: ```js // count documents in a collection db.getCollection("orders").countDocuments({ status: "paid" }) // find with a projection db.getCollection("users").find({ active: true }, { email: 1 }).limit(5).toArray() // an aggregation db.getCollection("events").aggregate([ { $group: { _id: "$type", n: { $sum: 1 } } }, { $sort: { n: -1 } } ]).toArray() ``` For read-only browsing (databases, collections, paginated documents as EJSON) you only need `Viewer` — see [Browsing your data](/docs/data-editor/browsing). ## Redis / Valkey — the command box The Redis view pairs the key browser with a **command box** that runs a single raw command and renders the reply as JSON. - Type a command like `GET mykey`, `HGETALL session:42` or `TTL token:abc` and run it. - The command line is tokenized with double-quote support, so `SET greeting "hello world"` keeps the quoted argument intact. - Replies are converted to JSON: bulk strings, integers, arrays/sets, maps and `OK` all map to their natural JSON shape. - The selected **database index (0–15)** applies to the command, the same as the key browser. Reads through the key browser (`SCAN`, type-aware `GET`/`LRANGE`/`HGETALL`/…, `TTL`) need only `Viewer`; running an arbitrary command needs `Editor`, since a command can mutate or delete data. ## Next steps - [Access & audit](/docs/data-editor/security) — roles and the `db_query_log` audit log. - [Overview](/docs/data-editor/overview) — supported engines and how Pier connects. --- ## Data editor — Overview > Browse and query your databases from the Pier dashboard. PostgreSQL, MySQL/MariaDB, MongoDB and Redis — no Adminer, no pgweb, no external client. Source: https://pier.team/docs/data-editor/overview ## TL;DR Every database service you deploy from Pier gets a **Data** tab. Open it to explore the schema, page through rows or documents, and run queries inline — without installing DBeaver, exposing the port to the internet, or running an Adminer/pgweb container alongside. It's part of the same Rust binary that runs your containers. Access is gated by Pier's RBAC, and every query you run through a runner is recorded in an audit log. ## Supported engines | Engine | How Pier connects | Browse | Query runner | | --- | --- | --- | --- | | **PostgreSQL** (incl. PostGIS, TimescaleDB) | native `sqlx` driver over TCP | databases · schemas · tables · views · structure · rows | arbitrary SQL | | **MySQL / MariaDB** | native `sqlx` driver over TCP | databases · tables · views · structure · rows | arbitrary SQL | | **MongoDB** | `mongosh` inside the container (docker-exec) | databases · collections · documents | `mongosh` scripts | | **Redis / Valkey** | native `redis` client over TCP | keys (`SCAN`) · type-aware values · TTL | raw commands | The SQL engines and Redis use **native Rust drivers**; MongoDB is driven through `mongosh` already present in the official image, so Pier avoids pulling a heavy native Mongo driver into the binary. > Other database engines in the catalog — **Cassandra/ScyllaDB** and **ClickHouse** — can be deployed and managed by Pier, but don't have a **Data** tab yet; browse those with their own clients for now. ## Where to find it 1. Open a database **resource** (anything you deployed from the **Databases** catalog category). 2. Click the **Data** tab. The tab only appears for database services. 3. Pier detects the engine and shows the matching view: - **SQL** engines → a schema/table browser with an inline **SQL Runner**. - **MongoDB** → a database/collection browser with a **Mongo Shell**. - **Redis** → a key browser with a command box. ## How the connection works Pier core runs **natively on the host**, next to the Docker daemon. Private database services aren't published to a host port, so Pier reaches them over the **`pier-net` Docker network**, dialing the container's internal IP and port (e.g. `5432`, `3306`, `6379`). If a port *is* published, the loopback (`127.0.0.1:{host_port}`) is used as a fallback. Credentials are read from the service's **encrypted environment** (`POSTGRES_PASSWORD`, `MYSQL_ROOT_PASSWORD`, `MONGO_INITDB_ROOT_PASSWORD`, `REDIS_PASSWORD`, …), decrypted in memory at request time. They're never shown in the UI or written to logs. Because everything happens server-side over the internal network, **you don't have to expose the database to the internet** to inspect it. ## What it is — and isn't The data editor is an **80% tool**: it covers the day-to-day — eyeball some rows, check a schema, run a quick `SELECT`, fix one record, inspect a key. It deliberately does **not** try to replace TablePlus/DataGrip/Compass for ER diagrams, query-plan profiling, or schema migrations. For a self-hosted PaaS that's the right scope: most of the time you just want to see your data without leaving the dashboard. ## Next steps - [Browsing your data](/docs/data-editor/browsing) — schemas, tables, structure, rows, collections and keys. - [SQL Runner](/docs/data-editor/sql-runner) — running queries against PostgreSQL and MySQL/MariaDB. - [MongoDB & Redis](/docs/data-editor/nosql) — the Mongo Shell and the Redis key browser. - [Access & audit](/docs/data-editor/security) — RBAC roles and the query audit log. --- ## Access & audit > How the Pier data editor is secured — RBAC roles for read vs. write, the db_query_log audit trail, encrypted credentials, and the internal-network connection model. Source: https://pier.team/docs/data-editor/security The data editor hands out direct database access, so it's deliberately fenced with RBAC, auditing and a server-side-only connection model. ## RBAC — read vs. write Access is enforced **per resource** by Pier's role-based access control: | Action | Minimum role | | --- | --- | | Browse databases / schemas / tables / collections / keys | `Viewer` | | View structure, rows, documents, key values | `Viewer` | | **SQL Runner** (any statement) | `Editor` | | **Mongo Shell** (any script) | `Editor` | | **Redis command** (any command) | `Editor` | The split is intentional: a teammate can be given **read-only** insight into production data without the ability to mutate it. Anything that *could* write — every runner, regardless of whether the specific statement happens to be a `SELECT` — requires `Editor`. ## Audit log Every runner execution is recorded in the `db_query_log` table, whether it succeeds or fails. Each row captures: - **Who** — user id and username. - **Where** — the service and the target database. - **What** — the SQL / command / script text, and its kind (`read`, `write`, `redis`, `mongo`). - **Outcome** — status (`ok` / `error`), row count, duration, and the error message on failure. Logging is best-effort and never blocks a query — an audit-write failure won't mask a successful (or failed) statement. Browsing reads aren't logged; the audit trail focuses on the runners, which are the actions that can change data. ## Credentials Connection credentials come from the service's **encrypted environment** (`env_json`, AES-256-GCM at rest) and are decrypted in memory only at request time. They are: - never displayed in the UI, - never written to logs, - read from the standard image variables — `POSTGRES_USER` / `POSTGRES_PASSWORD`, `MYSQL_ROOT_PASSWORD` (or `MARIADB_ROOT_PASSWORD`), `MONGO_INITDB_ROOT_USERNAME` / `MONGO_INITDB_ROOT_PASSWORD`, `REDIS_PASSWORD`. ## Connection model Pier core runs on the host and reaches each database over the **`pier-net` Docker network** — the container's internal IP and port — falling back to `127.0.0.1:{host_port}` only when a port is published. This means: - A private database **never has to be exposed to the internet** for you to inspect it. - The traffic stays on the internal Docker bridge between the host process and the container. ## Injection safety (SQL) For the SQL browser, identifiers (schema/table/column names) arrive as parameters but are never trusted blindly: - the `(schema, table)` pair is confirmed to exist via `information_schema` before any string-built SQL touches it, - every identifier and literal is engine-quoted, - column names always come from the catalog, never from the client. The SQL Runner itself executes the operator's statement as-is — that's the point of a runner — which is exactly why it's gated behind `Editor` and fully audited. ## Next steps - [Overview](/docs/data-editor/overview) — supported engines and where to find the Data tab. - [SQL Runner](/docs/data-editor/sql-runner) — limits and behavior of the SQL runner. --- ## SQL Runner > Run arbitrary SQL against PostgreSQL and MySQL/MariaDB from the Pier dashboard — reads return a grid, writes report affected rows, with a statement timeout, row cap and full audit log. Source: https://pier.team/docs/data-editor/sql-runner The **SQL Runner** executes an arbitrary statement against the selected database. It sits below the browser in the **Data** tab as a collapsible panel — closed by default, so you browse first and drop into the runner when you need it. Running a statement requires the **`Editor`** role on the resource, because it can mutate data. (Browsing only needs `Viewer` — see [Access & audit](/docs/data-editor/security).) ## Reads vs. writes Pier classifies each statement by its leading keyword (after stripping leading comments): - **Reads** — `SELECT`, `WITH`, `VALUES`, `TABLE`, `SHOW`, `EXPLAIN`, `DESCRIBE`/`DESC`. The result comes back as a **grid**. - **Writes** — anything else (`INSERT`, `UPDATE`, `DELETE`, DDL, …). The result is the **affected-row count**. The classification is conservative: anything not clearly a read is run as a write. A trailing semicolon is stripped automatically so single statements just work. ## Result grid & limits - **Row cap.** A read is capped at **1,000 rows**. If the result is larger, the grid shows the first 1,000 and flags the result as truncated — narrow it with a `WHERE` or `LIMIT` to see the rest. - **Every column is text.** Results are cast to text so any column type renders in the grid. If your statement returns **unnamed or duplicate** columns, add explicit aliases — Pier asks for them rather than guessing. - **Statement timeout.** Each connection sets a **15-second** statement timeout (`statement_timeout` on Postgres, `max_execution_time` on MySQL), so a runaway query can't pin the database. - **Duration.** Every run reports how long it took. ## Tips - Use the **Database** selector to target a specific database; leave it on the default to use the service's primary database. - For large tables, prefer `LIMIT`/`OFFSET` or a `WHERE` clause over scrolling the full grid. - Statements that return no rowset (for example a bare `SET`) are executed and reported as an affected-row count. ## Auditing Every runner execution — success or failure — is written to the `db_query_log` audit table: who ran it, the database, the SQL text, whether it was a read or write, the status, row count and duration. See [Access & audit](/docs/data-editor/security) for the full picture. ## Next steps - [MongoDB & Redis](/docs/data-editor/nosql) — the non-SQL runners. - [Access & audit](/docs/data-editor/security) — roles and the audit log. --- ## First deploy > Walk through deploying your first service on Pier in under five minutes. Source: https://pier.team/docs/getting-started/first-deploy ## The goal Deploy a small app behind HTTPS with a public domain. We will use `whoami` — a tiny container that prints request metadata, perfect for verifying the reverse proxy. ## Steps 1. Open the Pier dashboard and click **Add service → From Docker image**. 2. Enter image `traefik/whoami:latest`. 3. Give it a name — e.g. `whoami`. 4. Attach a domain you control (e.g. `demo.example.com`). Make sure the DNS A record points at your server IP. 5. Click **Deploy**. Pier will: - Pull the image. - Create the container and attach it to the `pier-net` Docker network. - Allocate an internal port. - Generate a Traefik router rule for your domain. - Request a Let's Encrypt certificate. After 10–30 seconds (first cert issuance is the slow step), `https://demo.example.com` serves the app. ## Verify ```bash curl https://demo.example.com ``` You should see request headers printed by `whoami`. ## What just happened - Pier wrote a Traefik dynamic-config file mapping `demo.example.com` → the service's internal port. - Traefik detected the file, issued an ACME cert, and is now terminating TLS at the edge. - The container itself binds only to the internal Docker network — it is not exposed to the internet directly. ## Next - [Learn how reverse proxy + SSL works](/docs/concepts/proxy-and-ssl). - Deploy something real from the [service catalog](/services). - Wire up [Git-to-deploy from a repo](/docs/applications/from-git). --- ## Installation > Install Pier on any Ubuntu or Debian server with a single command or from source. Source: https://pier.team/docs/getting-started/installation ## Requirements - **OS**: Ubuntu 22.04+ or Debian 12+ - **CPU**: 1 vCPU (any architecture with Docker support) - **RAM**: 512 MB minimum, 1 GB recommended - **Disk**: 2 GB free - **Docker**: 24.0 or newer - **Ports**: 80 and 443 open for Traefik; 8443 for the Pier dashboard ## Option A — one command (recommended) ```bash curl -fsSL https://pier.team/install | sudo bash ``` This script installs Docker if missing, downloads the latest Pier binary, creates a `pier` system user, installs the systemd unit, and starts Pier on port `8443`. When it finishes, open `https://YOUR_SERVER_IP:8443/setup` to create your admin account. ## Option B — build from source ```bash git clone https://github.com/joveptesg/pier.git cd pier cargo build --release sudo bash scripts/install.sh --binary target/release/pier ``` Requires a Rust 1.93+ toolchain. The resulting binary is around 15 MB once stripped. ## After installation 1. Open `https://YOUR_SERVER_IP:8443/setup`, create the admin account. 2. Add your first domain under **Settings → Domains** so Traefik can provision certificates. 3. Deploy a service from the [one-click templates](/services). ## Uninstall ```bash sudo systemctl stop pier sudo systemctl disable pier sudo rm -rf /opt/pier /etc/systemd/system/pier.service sudo systemctl daemon-reload ``` This removes the binary, data directory, and systemd unit. Docker containers created by Pier are left alone — remove them separately if needed. --- ## Introduction > Pier is a lightweight, self-hosted PaaS. Single Rust binary, 20 MB RAM, deploy anything. Source: https://pier.team/docs/getting-started/introduction ## TL;DR Pier is an open-source, self-hostable alternative to Coolify, Heroku, and Vercel. It packages container management, Docker Compose, reverse proxy with automatic HTTPS, Git-to-deploy, service templates, and backups into a **single ~15 MB Rust binary** that uses **20–40 MB of RAM** at idle. ## What you get - **Containers & stacks** — create, start, stop, restart, remove. Deploy any Docker Compose file with a built-in YAML editor. - **One-click templates** — 30+ curated services: PostgreSQL, Redis, Grafana, Gitea, Supabase, Matrix, Minecraft, and more. - **Git to deploy** — GitHub and GitLab webhooks. Build from a Dockerfile, a Docker image, or a Compose file. - **Reverse proxy with HTTPS** — Traefik + Let's Encrypt, auto-provisioned per service domain. - **Multi-server** — manage remote servers through lightweight Pier agents. - **Scheduled backups** — back up volumes and databases to any S3-compatible bucket. - **Self-update** — check for new builds from the UI, atomic binary swap with rollback. ## Why another PaaS? Coolify is excellent — the reason Pier exists is not to replace it, but to serve a different constraint. Coolify's Laravel/PHP stack runs 6+ containers (Laravel, PostgreSQL, Redis, Soketi, Horizon, Traefik) and uses 750 MB–1.2 GB of RAM *before* you deploy your first app. That rules out $5/month VPS hosts for serious use. Pier delivers the same core workflow from a single Rust binary using 20–40 MB of RAM. You can run it on a 512 MB VPS and still have room for three or four real apps. ## Next steps 1. [Install Pier](/docs/getting-started/installation) on any Ubuntu or Debian server with Docker. 2. [Deploy your first service](/docs/getting-started/first-deploy) from the dashboard. 3. Read the [architecture overview](/docs/concepts/architecture) to understand how Pier is wired internally. --- ## Uninstall > Remove Pier, its data, and its systemd service. Source: https://pier.team/docs/getting-started/uninstall ## Back up first Before uninstalling, save: - `/opt/pier/data/pier.db` — the SQLite database. - `/opt/pier/.env` — the encryption key (`PIER_SECRET`). Without it, the database cannot be decrypted. - `/opt/pier/data/backups/` — any automated backups. ## Remove Pier ```bash sudo systemctl stop pier sudo systemctl disable pier sudo rm /etc/systemd/system/pier.service sudo rm -rf /opt/pier sudo systemctl daemon-reload ``` ## Clean up containers Pier does not remove the Docker containers it created. If you want to wipe everything: ```bash docker ps -a --filter "label=pier.service" --format "{{.Names}}" | xargs -r docker rm -f docker volume ls --filter "label=pier.service" --format "{{.Name}}" | xargs -r docker volume rm docker network rm pier-net ``` --- ## Upgrade > How to upgrade Pier to a newer version without losing state. Source: https://pier.team/docs/getting-started/upgrade ## From the dashboard 1. Open **Settings → Updates**. 2. Click **Check for Updates** — Pier compares the installed binary with the latest GitHub release. 3. If a newer build is available, click **Update & Restart**. Since **1.4.3** the button asks a small root service, `pier-updater`, to re-run the official installer on the server. That refreshes everything the installer owns: - the `pier` binary; - `pier-net-helper` (the privileged helper) and `pier-agent`; - their systemd units, including the updater itself. Pier restarts on the way; the page follows the progress and shows the installer log if something fails. `.env` and `/opt/pier/data` are preserved. The panel can only *ask* for an update — the request carries no parameters. What gets installed is decided on the server: the latest release, verified by sha256. ## Servers installed before 1.4.3 Older installs don't have the updater, and the panel shows a banner about it. Until it is installed, the button can only swap the `pier` binary through `pier-net-helper` — and fails outright if the helper isn't running (for example after a reboot: older helper units had no `[Install]` section). Run the installer **once** over SSH; after that every dashboard update covers everything: ```bash curl -fsSL https://pier.team/install | sudo bash # Check: both lines must say "enabled", and the file must exist systemctl is-enabled pier-net-helper pier-updater.path ls -l /usr/local/sbin/pier-update ``` ## When an update fails ```bash cat /var/lib/pier-updater/status.json # last run: running / success / failed cat /var/lib/pier-updater/last.log # full installer output journalctl -u pier-updater -n 50 --no-pager ``` Re-running the one-liner is always safe — it performs the same update by hand. If it fails with `404` within ~10 minutes of a new commit, CI is republishing the `latest` release: wait and retry. ## Manual upgrade The one-liner above is the manual upgrade: it downloads the latest release, verifies its sha256, keeps your port and data, and restarts Pier. ## Rollback Install a specific release instead of `latest`: ```bash curl -fsSL https://pier.team/install | sudo bash -s -- --release-tag v1.4.2 ``` Releases are listed at [github.com/joveptesg/pier/releases](https://github.com/joveptesg/pier/releases). Database migrations only move forward and are not undone by a rollback. If you might need to go back, back up `/opt/pier/data` before upgrading. --- ## Why Pier > Why Pier exists and when to pick it over Coolify or another self-hosted PaaS. Source: https://pier.team/docs/getting-started/why-pier ## Pick Pier if - You are running on a **small VPS** — 512 MB to 2 GB of RAM — and want every megabyte available for the apps you deploy. - You want a **single binary** with embedded assets, embedded SQLite, and a single systemd unit to manage. - You prefer **Rust's memory model** and static binaries to a PHP/Node runtime with 6+ support containers. - You want the workflow of Coolify or Heroku without their infrastructure footprint. ## Pick Coolify if - You need the depth of Coolify's feature set today — preview deployments, team roles, tunnels, destination management. Pier is catching up but is younger. - You want Laravel-based customization or a larger ecosystem of third-party integrations. - Your servers are already large enough that RAM and disk are not scarce. ## The numbers | | Coolify | Pier | |---|---|---| | Idle RAM | 750 MB – 1.2 GB | 20–40 MB (+Traefik) | | Disk footprint | ~1 GB | ~15–30 MB | | Support containers | 6+ | 1 binary + Traefik | | Minimum VPS | 2 GB RAM, 2 vCPU | 512 MB RAM, 1 vCPU | | Database | External PostgreSQL | Embedded SQLite | | Language | PHP / Laravel | Rust | | Frontend JS | ~300 KB+ | ~30 KB (HTMX + Alpine.js) | ## What Pier does not try to be - Pier is **not a Kubernetes distribution**. It manages Docker on one or more servers, not a cluster scheduler. - Pier is **not a serverless platform**. Workloads are long-running containers. - Pier is **not a multi-tenant SaaS**. Every Pier instance is for a single team or individual. --- ## Database clusters > How Pier runs cross-server database clusters — MongoDB replica sets, Redis Sentinel, Cassandra and ScyllaDB rings — with node scaling and automatic connection wiring. Source: https://pier.team/docs/networking/clusters Some databases are best run as a cluster: several nodes that replicate data and survive a node failure. Pier can deploy these clusters across multiple servers on the [WireGuard mesh](/docs/networking/mesh), so the nodes live on different hosts but still reach each other over the private overlay. ## Which engines support clustering Clustering is opt-in per engine, declared in the catalog template. Seven engines support it: | Engine | Topology | Min nodes | Max nodes | | --- | --- | --- | --- | | PostgreSQL | Streaming replication (1 primary + read replicas) | 2 | 5 | | MySQL | Streaming replication (1 primary + read replicas) | 2 | 5 | | MariaDB | Streaming replication (1 primary + read replicas) | 2 | 5 | | MongoDB | Replica set with automatic primary election | 3 | 5 | | Redis | Sentinel HA (1 master + replicas + sentinels) | 3 | 5 | | Cassandra | Gossip ring with seed-based discovery | 2 | 5 | | ScyllaDB | Gossip ring (Cassandra-compatible) | 2 | 5 | The default node count for every clustered engine is **3**. MongoDB and Redis require at least three nodes so their quorum-based elections work. Engines without a cluster section — for example PostGIS, TimescaleDB, Valkey and ClickHouse — deploy as a single standalone instance. ## Single-server vs. cross-server You choose a cluster when creating the resource by selecting cluster mode and assigning each node to a server: - If every node lands on **one server**, Pier builds a single-host Compose stack for the cluster. - If nodes are spread across **multiple servers**, Pier deploys a distributed cluster. Every target server must be on an **active WireGuard mesh**, or the request is rejected — the nodes reach each other by mesh IP and published host port. Cassandra and ScyllaDB have an extra constraint when distributed: one node per server, because their gossip port is fixed and two nodes on the same host would collide. ## Scaling Change the total node count with `POST /api/v1/resources/{id}/scale`, passing the new `node_count`. Pier validates it against the engine's min and max bounds, regenerates the cluster, and preserves the existing nodes' placement. Removing a node uses the engine's own decommission path where one exists — `nodetool decommission` for Cassandra and ScyllaDB, `rs.remove()` on the primary for MongoDB. > Load balancing (`/load-balance`) is for **stateless service replicas**, not database clusters. Clustered databases are scaled through `/scale`; the load-balance endpoint rejects cluster-mode services. ## Automatic connection wiring Inspect a cluster's nodes and connection details with `GET /api/v1/resources/{id}/nodes`. For distributed clusters, Pier assembles the right connection shape for the engine automatically: - **MongoDB** — a replica-set URI listing every node's mesh address, ending in `?replicaSet=rs0`. - **Redis** — a Sentinel configuration: the list of sentinel addresses plus the master set name your client connects through. Other engines return their node list without a synthesized URI; you point your application at the primary (or contact points) as appropriate for that engine. ## Next steps - [WireGuard mesh](/docs/networking/mesh) — the overlay distributed clusters depend on. - [Servers & agents](/docs/networking/servers) — add the servers your cluster nodes run on. - [Projects & services](/docs/concepts/projects-and-services) — how databases fit into a project. --- ## Federation > How a primary Pier core aggregates a read-only view of peer cores and controls their stacks — sync, peer token grants, write federation, and stack migration. Source: https://pier.team/docs/networking/federation Federation links **independent Pier cores**. A primary core can pull a read-only view of its peers' projects and stacks, and — when granted a token — deploy, restart and tear down stacks on those peers from one panel. Each core keeps its own database; federation is a control overlay, not a merged install. Federation grants are **Owner-only**; the read and write surfaces below are admin-level actions. ## Read federation (sync) A background scheduler polls every registered peer and refreshes a local cache of its projects and stacks. The sync runs roughly every **45 seconds** (after a short startup delay), fetching projects and stacks from each peer's normal read API and replacing the cached rows for that peer inside a transaction. A failed poll leaves the last-known data in place rather than blanking the cache, so the panel can show how stale a peer's view is. The cached, read-only view is exposed on the primary at: - `GET /api/v1/federation/projects` — local plus cached peer projects, tagged by source. - `GET /api/v1/federation/stacks` — the same for stacks, including whether each peer is paired for writes. - `GET /api/v1/federation/status` — per-peer sync bookkeeping (last sync, last error, consecutive failures). - `POST /api/v1/federation/sync` — trigger an out-of-band refresh instead of waiting for the timer. ## Peer token grants Inter-core authentication uses **federation tokens**. On the peer, the operator mints a token (`POST /api/v1/federation-tokens` with a label); the plaintext is shown exactly once and stored only as a SHA-256 hash plus a short visible prefix. The operator copies it into the primary, which sends it on write calls in the `X-Pier-Federation` header. Manage tokens on the peer at: - `GET / POST /api/v1/federation-tokens` — list and mint. - `DELETE /api/v1/federation-tokens/{id}` — revoke (a soft revoke that keeps the row for audit). A federation token authorizes **only** the peer's federation control surface — creating and managing the stacks the primary deploys. It cannot touch the peer's users, sessions or admin settings, and it does not grant control over stacks the peer already owns itself. ## Write federation With a token in place, the primary controls a peer's stacks through passthrough routes that forward to the peer over the `X-Pier-Federation` channel: | Action | Route (primary) | | --- | --- | | Create stack | `POST /api/v1/federation/peer/{server_id}/stacks` | | Deploy | `POST /api/v1/federation/peer/{server_id}/stacks/{stack_id}/deploy` | | Down | `POST /api/v1/federation/peer/{server_id}/stacks/{stack_id}/down` | | Restart | `POST /api/v1/federation/peer/{server_id}/stacks/{stack_id}/restart` | | Logs | `GET /api/v1/federation/peer/{server_id}/stacks/{stack_id}/logs` | | Release | `POST /api/v1/federation/peer/{server_id}/stacks/{stack_id}/release` | On the peer, these land on a dedicated `/api/v1/agent` router gated by the federation token. Each stack created this way is owned by the issuing token: the peer enforces ownership, so one primary can't manage another primary's federated stacks, and **releasing** a stack returns it to the peer's own panel without touching its running containers. ## Migrating stacks between peers You can move a stack from the local core to a paired peer with `POST /api/v1/stacks/{id}/migrate`, passing the target server. Migration is limited to **stateless Compose stacks** — a stack with named or anonymous volumes is refused, since its data wouldn't travel with it. The pipeline creates and deploys the stack on the target, then tears down the source and removes its local record. > DNS cut-over is not automated. Each server runs its own Traefik, so after a migration you re-point the stack's domain to the new server yourself. The response returns the source domains as advice for exactly this step. ## Next steps - [Servers & agents](/docs/networking/servers) — register the peers you federate with. - [WireGuard mesh](/docs/networking/mesh) — the encrypted overlay between cores. - [Projects & services](/docs/concepts/projects-and-services) — what a federated stack contains. --- ## WireGuard mesh > How Pier connects multiple servers with a host-level WireGuard overlay, pairs cores into a peer mesh, and resolves logical service names across the network. Source: https://pier.team/docs/networking/mesh A single Pier server is self-contained, but once you add remote servers you usually want them to talk over a private, encrypted network rather than the public internet. Pier builds that network as a **host-level WireGuard overlay**: every server gets a stable private IP, and traffic between them rides an encrypted tunnel. Mesh management is **Owner-only**. Every endpoint described here lives behind the global Owner role gate. ## How the overlay is built The mesh is assembled in deliberate, reviewable steps so a mistake stalls the wizard rather than breaking a live tunnel: | Step | Endpoint | What happens | | --- | --- | --- | | Configure | `PUT /api/v1/network/mesh` | Set the subnet, listen port and keepalive. Only allowed while the mesh is disabled. | | Preflight | `GET /api/v1/network/mesh/preflight` | Checks every server for a reachable `pier-net-helper` before you commit. | | Enable | `POST /api/v1/network/mesh/enable` | Allocates a private IP to every server and flips the mesh on. | | Configure (apply) | `POST /api/v1/network/mesh/configure` | Installs WireGuard, mints a keypair per node, writes `wg0.conf` and brings each tunnel up. | | Disable | `POST /api/v1/network/mesh/disable` | Brings every interface down and clears the peer rows. | The subnet must be a `/30` or larger prefix — `/31` and `/32` leave no usable hosts and are rejected. The local core is always assigned the first host address, so you can hard-code it in env files and runbooks. > The configure pass is sequenced so a partial failure stops early and leaves a recoverable state. A peer that errors is flagged with the helper's message; you clear it by disabling and re-enabling, then re-running configure. ## The pier-net-helper Pier never runs privileged WireGuard commands from the core or agent process. Each server runs a small root daemon, **`pier-net-helper`**, that listens on a Unix socket — `/run/pier/net.sock` by default, overridable with the `PIER_NET_HELPER_SOCKET` environment variable. The core talks to the local helper directly over that socket; for remote servers it routes the same operations through the agent's mesh proxy over the pinned HTTPS channel. Private keys are generated on the node and stay there. The core renders each `wg0.conf` without a `PrivateKey` line — the helper injects the node-local key when it writes the file. The **mesh preflight** check exists because of this split: it fans out a status probe to every server and refuses to start the Enable wizard if any node is missing a reachable helper, so you can run the retrofit installer up front instead of discovering the gap mid-provision. ## Core-to-core peer mesh The steps above connect a core to the servers it manages directly (the local node and its agents). To connect **two independent cores**, you pair their meshes: - `POST /api/v1/network/mesh/pair/{id}` — pair this core's mesh with a registered peer core. - `POST /api/v1/network/mesh/peer/{id}/unpair` — tear the pairing down on both sides. Pairing is owner-allocated: the initiating core allocates mesh IPs for the remote core's nodes from its own subnet, ships the plan over the peer-token channel, and stores the remote nodes as external peers so its own `wg0.conf` gains `[Peer]` blocks for them. In this version both cores must have already registered each other as peers, and the target core's mesh must be **disabled** before pairing. ## Service DNS On top of the overlay, you can register **logical service names** that resolve to whichever node currently hosts a service. A name like `db` becomes `db.mesh`, injected as an `extra_hosts` entry into every stack on deploy, so consumer services connect to a stable name instead of an IP. Manage these mappings (Owner-only) at: - `GET / POST /api/v1/network/service-dns` - `PUT / DELETE /api/v1/network/service-dns/{name}` Names follow tightened RFC 1123 label rules — lowercase letters, digits and hyphens, starting and ending alphanumeric, up to 31 characters — and cannot collide with an existing server name. Adding or changing a mapping queues a background redeploy so running stacks pick up the new `extra_hosts` entry. ## Next steps - [Servers & agents](/docs/networking/servers) — add the remote servers that join the mesh. - [Database clusters](/docs/networking/clusters) — cross-server clusters that rely on the overlay. - [Networking](/docs/concepts/networking) — the per-host `pier-net` Docker network. --- ## Servers & agents > How Pier enrolls remote servers as agents — the install script, bootstrap-token handshake, TLS fingerprint pinning, metrics, deploying onto an agent, and promoting an agent to a full core. Source: https://pier.team/docs/networking/servers A Pier core can manage more than its own host. You add a remote server as an **agent** — a lightweight, stateless daemon (`pier-agent`) that the core drives over a pinned HTTPS channel. The core stays the single control plane; the agent just runs Docker commands and reports metrics. Adding, removing and deploying to servers are admin-level actions in the panel. ## Enrolling an agent When you create an agent-kind server, the core mints a short-lived **bootstrap token** and hands you an install command. The script (served from `GET /api/v1/servers/install-script`) runs on the new host and: 1. Installs Docker and the Compose plugin if missing, and creates the `pier-net` Docker network. 2. Downloads the `pier-agent` binary from the core, authenticated with the bootstrap token. 3. Installs `pier-net-helper` (dormant — it does nothing until the core sends a mesh op). 4. Generates a self-signed TLS certificate for the agent and computes its SHA-256 leaf fingerprint. 5. Performs the **handshake**, exchanging the one-shot bootstrap token for a long-term agent token. 6. Confirms liveness with a first heartbeat. The install script pins the core's own self-signed certificate via `curl --cacert`, so the agent calls back over verified HTTPS rather than `-k`. ## Bootstrap to bearer handshake The bootstrap token is **one-shot and short-lived**. The agent spends it at `POST /api/v1/servers/{id}/handshake`, and the core mints the long-term agent token in response. That long-term token is returned exactly once and stored on the core only as a SHA-256 hash; the agent keeps the plaintext in its systemd environment file. > A second handshake against an already-redeemed bootstrap is rejected. If the install command leaked or was re-run, recreate the server in the panel to issue a fresh bootstrap — Pier won't silently re-issue a credential. Thereafter the agent authenticates every request with `Authorization: Bearer `. You can rotate it from the panel (`POST /api/v1/servers/{id}/rotate`): the core pushes the new token to the agent first, and only persists it after the agent acknowledges, so a failed rotation never leaves the core out of sync. ## Certificate fingerprint pinning The core-to-agent channel is HTTPS, but agents are reached by raw IP (or mesh IP) with no PKI chain. Instead of validating a certificate authority, the core **pins the agent's TLS leaf fingerprint** — the SHA-256 the install script computed and sent during the handshake. Every subsequent core-to-agent call validates against that pin, so the channel is protected against certificate swaps. A re-affirmed fingerprint can arrive on a later heartbeat to re-pin after the agent regenerates its cert. ## Metrics and deploys Once enrolled, an agent exposes: - **Metrics** — CPU, memory, disk, Docker version and container count, surfaced in the server card (`GET /api/v1/servers/{id}/metrics`, proxied to the agent's `/metrics`). - **Deploys** — deploy or stop a Compose stack on the agent (`POST /api/v1/servers/{id}/deploy` and `/stop`). The core sends the rendered Compose YAML; the agent writes it under its data dir and runs `docker compose`. The core also polls registered peers on a timer, while agents push their own heartbeat, so the panel reflects each server's status. ## Promoting an agent to a core An agent can be **promoted** into a full `pier-core`. The core exports a promotion bundle from its database and sends it to the agent (`POST /api/v1/servers/{id}/promote`). The agent writes the bundle, downloads the `pier-core` binary, imports the bundle into a fresh database, installs a systemd unit, stops `pier-agent` and starts `pier`. The promotion runs detached, so the agent can stop itself as the new core takes over. ## Next steps - [WireGuard mesh](/docs/networking/mesh) — connect your servers over an encrypted overlay. - [Federation](/docs/networking/federation) — link independent cores and control peer stacks. - [Projects & services](/docs/concepts/projects-and-services) — what you deploy onto a server. --- ## Alerts & notifications > Metric threshold alerts and event notifications in Pier — CPU/RAM/disk rules, Discord/Slack/Telegram/Email channels, test notifications, the event feed, and preset alerts. Source: https://pier.team/docs/operations/alerts Pier watches host and container metrics, plus lifecycle events like deploys and backups, and sends notifications when something crosses a threshold or fails. ## Alert rules A rule pairs a metric with a threshold, a comparison and a severity. Numeric rules are evaluated on a recurring tick (default every 30 seconds); event rules fire from the code path that produced the event. Available metrics: | Group | Metrics | | --- | --- | | Host | `cpu`, `ram`, `disk` | | Container | `container_cpu`, `container_ram`, `container_status`, `container_restarts` | | Lifecycle | `deploy_status`, `deploy_success`, `backup_status`, `backup_success` | | Infra | `agent_offline`, `server_reachable`, `ssl_expiry`, `docker_cleanup_success`, `docker_cleanup_failure` | Each rule also carries: - **comparison** — `gt`, `lt` or `eq`. - **severity** — `info`, `warning` or `critical`. - **scope** — `global` or a specific service. - **duration** — how long a breach must hold before firing (default 60s). - **cooldown** — minimum gap between repeat fires while still breached (default 30 minutes). A rule fires once its breach has held for the configured duration, re-fires only after the cooldown elapses, and automatically resolves when the value returns under threshold. ## Notification channels Pier ships four channels. Each has one global configuration, stored encrypted: | Channel | Configuration | | --- | --- | | Telegram | Bot token + chat ID | | Email | Driver (`smtp`, `brevo` or `resend`) + from/to addresses | | Discord | Webhook URL + optional `@here` ping | | Slack | Webhook URL | For SMTP email, the encryption mode (`starttls`, `tls`, `none`), host, port (default 587) and credentials are configured per channel. Brevo and Resend send through their HTTP APIs with an API key instead. > Telegram delivery hits `api.telegram.org` directly. If your server's network blocks that host, Telegram notifications will fail to send even though the configuration is valid. ## Test notifications Each channel has a **Test** action that sends a sample message through the live configuration, so you can confirm credentials and connectivity before relying on the channel. Individual rules can also send a test message tagged `[TEST]`. ## The event feed Every fire and resolve is recorded as an event with its delivery status. Two views are available: - A **global feed** of the most recent events across all rules. - A **per-rule history** of that rule's recent events. Failed deliveries are kept with the error message attached, so a misconfigured webhook or bad SMTP host is visible in the feed rather than silently dropped. ## Preset alerts Pier seeds a set of ready-made preset rules so you don't start from a blank slate. Presets are toggled on or off rather than built field-by-field, and they deliver through the global channel configuration above instead of carrying their own credentials. ## Next steps - [System maintenance](/docs/operations/maintenance) — Docker cleanup events feed alerts too. - [Backups](/docs/operations/backups) — back up databases the alerts watch over. - [Troubleshooting](/docs/troubleshoot/overview) — when notifications don't arrive. --- ## Backups > Per-database backups for PostgreSQL, MySQL and MongoDB — compressed dumps, cron schedules, offsite S3/Bunny storage, restore (including direct upload), and the daily system backup of pier.db and .env. Source: https://pier.team/docs/operations/backups Pier backs up databases by dumping them inside the running container, compressing the output, and shipping it to object storage. Backups are configured per service and run on a cron schedule, with retention enforced after every successful run. ## Supported engines Backups are available for PostgreSQL (including PostGIS and TimescaleDB), MySQL, MariaDB and MongoDB. Each engine produces a different on-disk format: | Engine | Dump tool | Stored format | | --- | --- | --- | | PostgreSQL / PostGIS / TimescaleDB | `pg_dump -Fc` (custom format) | `.dump` (self-compressed) | | MySQL / MariaDB | `mysqldump` | `.sql.gz` (gzipped in Pier) | | MongoDB | `mongodump --archive --gzip` | `.archive.gz` | PostgreSQL dumps always run as the cluster superuser so extension schemas (e.g. PostGIS `tiger`/`topology`) are captured cleanly. ## Per-database vs. cluster-wide A schedule with a `database_name` set backs up a single logical database. A schedule with no database name is **cluster-wide**: for SQL engines it loops over every database it knows about and bundles them into a single `.tar.gz` (entries named `.dump` or `.sql`); for MongoDB it runs one full-instance `mongodump`. > Restore is per-database only. Cluster-wide tar archives are supported as a *source* — Pier extracts the one database you're restoring into — but you can't restore a whole cluster in one action. ## Schedules Each schedule stores a cron expression, a retention count and the target S3 storage. Defaults when you create one: | Field | Default | | --- | --- | | Cron | `0 2 * * *` (daily at 02:00) | | Retention | `7` backups kept | Schedules are driven by the unified scheduler (see [Scheduled tasks](/docs/operations/scheduled-tasks)) — a backup schedule appears in the Schedules list as a `backup` action. You can also trigger an ad-hoc run at any time; an ad-hoc per-database run falls back to the cluster-wide schedule's storage if no per-database schedule exists yet. ## Offsite storage Backups upload to S3-compatible object storage or to Bunny.net. A storage entry holds the endpoint, region, bucket, access key, secret key and an optional key prefix (default `pier-backups`). Keys are laid out as `{prefix}/{service}/{filename}`. Use **Test** to verify the connection before pointing a schedule at it. ## Retention After each successful backup, Pier keeps the newest `retention_count` completed backups for that schedule and deletes the rest — both the object-storage blob and the database record. A failed cleanup is logged but never fails the backup itself. ## Restore There are two restore paths, both **per-database** and both destructive — the target database is dropped and recreated, so the target must already exist in the service (its stored credentials supply the owner role and password): - **Restore from a stored backup** — downloads the blob from storage, extracts the database from a cluster tar if needed, and replays it via `pg_restore`/`psql`/`mysql`/`mongorestore`. - **Restore from a direct upload** — upload a `.dump`, `.sql.gz`, `.tar.gz` or `.archive.gz` file by hand. This is the cross-cluster disaster-recovery path: it works on a fresh Pier instance with no storage configured. The format is detected from the filename and validated against the target engine. Backups can also be downloaded as files and deleted individually. ## System backup of pier.db + .env Independently of database backups, Pier takes a daily local snapshot of its own state: `pier.db` and `.env` are copied into `data/backups/system/`, timestamped per day, with the last 7 days retained. This protects the control-plane database and encryption configuration; it is not shipped offsite. ## Next steps - [Alerts & notifications](/docs/operations/alerts) — get notified when a backup fails. - [Scheduled tasks](/docs/operations/scheduled-tasks) — the scheduler that fires backups. - [Access & audit](/docs/data-editor/security) — how database credentials are protected. --- ## System maintenance > Keep a Pier host healthy — Docker prune for images/containers/build cache, disk-usage reporting, BuildKit cache prune, journald system logs with a live WebSocket tail, timezone, and the self-update flow. Source: https://pier.team/docs/operations/maintenance The System area covers the housekeeping that keeps a Pier host from filling up or falling behind: pruning Docker, watching disk usage, reading service logs, and updating Pier itself. ## Docker cleanup Pier can prune four kinds of Docker data. The manual **Clean** action runs an immediate `prune -f` for the target you pick: | Target | Command | | --- | --- | | Images | `docker image prune -f` | | Build cache | `docker builder prune -f` | | Containers | `docker container prune -f` | | Railpack/BuildKit cache | `buildctl prune` inside the buildkit container | Cleanup also runs on a schedule. The cleanup settings — enabled, interval, and which targets to prune — are mirrored into a system schedule in the unified scheduler. The default cadence is daily; intervals of 1, 6, 12, 24, 48 or 168 hours map to the matching cron expression. By default the scheduled run prunes images and build cache, leaves containers alone, and leaves the Railpack/BuildKit cache off. > The manual Railpack clean is aggressive — it wipes the BuildKit cache completely (`--keep-storage 0 --keep-duration 0`). The scheduled pass is conservative, keeping roughly 10 GB / 7 days of cache so it doesn't fight with Auto-build. ## Disk usage A disk-usage view breaks down Docker's footprint — images, containers and volumes individually, plus the build-cache total — sorted largest-first, with a grand total. A separate cleanup-info summary reports how much is reclaimable, including the Railpack/BuildKit cache measured from inside the buildkit container. When Auto-build is disabled or the buildkit container isn't running, the BuildKit figure simply reads 0 rather than erroring. ## System logs Pier exposes journald logs for a tightly allow-listed set of systemd units (`pier` and `pier-agent`) so you can debug runtime and startup issues without SSH. Two modes: - **Snapshot** — a one-shot pull, filterable by time window (5 min through 7 days, or all), line count (30 / 100 / 500 / 1000 / 5000) and priority (`err`, `warning`, `info`, `debug`). - **Live tail** — a WebSocket stream (`journalctl -f`) that pushes new lines as they arrive. Only the allow-listed units can be queried; anything else is rejected at the API. ## Timezone A single IANA timezone (e.g. `Europe/Moscow`) is stored in settings and used to format the timestamps in outbound notifications. The name is validated before it's saved, and unknown values fall back to UTC. ## Self-update Pier checks GitHub releases for a newer build and compares the published binary against the running one. Update behaviour is controlled by a mode setting: | Mode | Behaviour | | --- | --- | | `notify` | Tell you an update is available (default) | | `auto` | Apply updates automatically | | `manual` | Don't check or prompt | Applying an update downloads the binary, stages it in the data directory, and asks the privileged local helper to swap it in and restart Pier. On installs without the helper, Pier falls back to a direct swap and `systemctl restart`; if the install is hardened and that isn't permitted, it surfaces a manual-update hint instead. ## Next steps - [Scheduled tasks](/docs/operations/scheduled-tasks) — the scheduler that drives cleanup. - [Alerts & notifications](/docs/operations/alerts) — cleanup runs emit alert events. - [Security hardening](/docs/security/overview) — the sandboxing the updater works around. --- ## Scheduled tasks > Run saved shell commands on any server — task templates, ad-hoc one-off runs with live log polling, cron schedules, and the unified scheduler that also drives backups and cleanup. Source: https://pier.team/docs/operations/scheduled-tasks Pier lets you run shell commands on your servers — either on demand or on a cron schedule — and stores reusable commands as templates. The same scheduler that fires these tasks also drives backups and Docker cleanup. ## Task templates A template is a saved shell command plus defaults: a name, an optional description, a default timeout and a default environment map. Templates are the reusable building block — pick one, choose a server, and run it. ## Ad-hoc runs You can start a run two ways: from a template, or with an inline one-off command. If you supply both, the inline command wins. Each run targets one server, takes an optional environment override, and a timeout (default 1800 seconds, clamped between 1 second and 24 hours). The command runs on the target server's **pier-agent**, and Pier polls the agent roughly once a second to mirror stdout, stderr and status back into the run record — so the UI shows a near-live log tail. A run ends in one of these terminal states: | Status | Meaning | | --- | --- | | `success` / `failed` | Agent finished; see exit code | | `cancelled` | You cancelled it (agent is told to stop) | | `timeout` | Exceeded the run's timeout | | `unreachable` | Agent couldn't be contacted | > Tasks run through pier-agent. The local server and peer-type servers don't run shell tasks this way. ## Schedules A schedule attaches a cron expression and timezone to an action. Three action types exist: | Action | What it does | | --- | --- | | `task` | Runs a task template on a chosen server | | `backup` | Fires a [backup schedule](/docs/operations/backups) | | `cleanup` | Runs the [Docker cleanup](/docs/operations/maintenance) pipeline | Backup and cleanup schedules are managed from their own screens but appear here in the unified Schedules list, so you have one place to see what runs when. Each schedule keeps a history of its recent runs, and you can enable, disable or run one immediately. ## The unified scheduler A single scheduler ticks every 60 seconds and fires any schedule whose next run time has passed. It is deliberately conservative about overlap and missed runs: - **No double-firing.** If a schedule's previous run is still in flight, the new tick records a `skipped` run instead of starting a second one. - **No replay.** A long downtime simply skips the runs that would have happened; `next_run_at` is recomputed forward, never backfilled. ## Cron format and validation Pier accepts the conventional 5-field cron format (`m h dom mon dow`) as well as the common shortcuts `@hourly`, `@daily`, `@weekly`, `@monthly` and `@yearly`. Expressions are validated when you create or edit a schedule — an invalid one is rejected with the parser's error. A validate endpoint also previews the next several fire times for a given expression and timezone, so you can confirm a schedule does what you expect before saving it. ## Next steps - [Backups](/docs/operations/backups) — the backup action the scheduler fires. - [System maintenance](/docs/operations/maintenance) — the cleanup action and its cadence. - [Alerts & notifications](/docs/operations/alerts) — get told when a scheduled run fails. --- ## Use the registry in CI > GitHub Actions and GitLab CI snippets for installing from and publishing to a Pier-hosted registry. Works for npm, yarn, pnpm and bun. Source: https://pier.team/docs/registry/ci CI uses the same `.npmrc` (or `.yarnrc.yml`) as a developer workstation — the only difference is the token comes from a secret store instead of a file on disk. ## Step 1: store the token as a secret 1. Mint a CI-specific token in **Packages → Manage tokens** named `ci-github` (or similar). Use a separate token per pipeline so you can revoke it independently. 2. Add it as a secret: - **GitHub Actions** → repo Settings → Secrets and variables → Actions → `PIER_NPM_TOKEN`. - **GitLab CI** → project Settings → CI/CD → Variables → `PIER_NPM_TOKEN` (protected, masked). ## Step 2: write the `.npmrc` in the job ### GitHub Actions — npm / pnpm / bun ```yaml name: build on: [push] jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 24 - name: Configure Pier registry run: | cat > .npmrc < .yarnrc.yml < .npmrc < Bun's package manager talks to Pier via .npmrc — same protocol as npm, plus the abbreviated packument header for faster installs. Source: https://pier.team/docs/registry/clients/bun Bun's package manager (`bun add`, `bun install`) speaks the **standard npm registry protocol** and reads `.npmrc` — identical to npm. The two interesting bits are: - **Abbreviated packument** — bun sends `Accept: application/vnd.npm.install-v1+json` by default. Pier returns a leaner JSON payload (no README, no historical times), making installs noticeably snappier on large packages. - **Always sends bearer.** Like npm and pnpm, bun sends auth on every request — no `always-auth=true` workaround needed. ## Configure ```ini # .npmrc registry=https://YOUR-PIER-HOST/registry/npm/ //YOUR-PIER-HOST/registry/npm/:_authToken=pier_npm_… always-auth=true ``` `always-auth=true` is harmless for bun. Keep it so the same `.npmrc` also works for yarn 1 if you ever need it. ## Install / add ```bash bun install bun add left-pad bun add @your-org/internal-lib bun add -d typescript # devDependency ``` ## Publish ```bash bun publish ``` Bun supports `bun publish` from version 1.1+. It uses the same wire format every npm CLI uses (CouchDB-style PUT with `_attachments`). ## Login Bun doesn't have its own browser login flow. Use `npm login --auth-type=web` against Pier — bun reads the same `.npmrc`: ```bash npm login --auth-type=web --registry=https://YOUR-PIER-HOST/registry/npm/ ``` ## Abbreviated packument savings The abbreviated packument strips fields not needed for install resolution: | Field | Full | Abbreviated | |---|---|---| | `name` | ✓ | ✓ | | `dist-tags` | ✓ | ✓ | | `versions[*]` | full manifest | only install-time fields | | `versions[*].dist.{tarball,integrity}` | ✓ | ✓ | | `versions[*].readme` | ✓ | ✗ | | `versions[*].time` | ✓ | ✗ | | `time` (history map) | ✓ | ✗ | For popular packages (`next`, `react`, etc.) the abbreviated form is **5–10× smaller**. Pier serves both; the choice is driven by the `Accept` header. ## Versions matrix | bun version | Status | Notes | |---|---|---| | latest (1.x) | ✓ | Tested in pier-tests matrix against current stable | Pin via `packageManager` if you want lockstep behavior: ```json { "packageManager": "bun@1.1.0" } ``` ## Common errors - **`error: not found: `** — upstream proxy off + package not privately published. Enable proxy. - **`error: Failed to authenticate`** — token missing or revoked. Mint a fresh one. - **`error: file not found`** on a scoped package — the URL-encoding for `@scope/name` might be mangled by an intermediate proxy. Test the raw fetch: `curl -H "Authorization: Bearer pier_npm_…" https://pier-host/registry/npm/@scope%2Fname` — should return packument JSON. ## Related - [Setup](/docs/registry/setup) — base walkthrough. - [CI integration](/docs/registry/ci) — bun in CI snippets. - [Upstream proxy](/docs/registry/proxy) — when bun installs hit the cache. --- ## Use npm with Pier > Install and publish from npm 7 / 8 / 9 / 10 / 11 against a Pier-hosted registry. .npmrc config, common commands and the always-auth quirk. Source: https://pier.team/docs/registry/clients/npm Pier's registry tracks the **npm registry HTTP spec** end-to-end, so every npm release from **7 through 11** works against it without bespoke flags. ## Configure `.npmrc` in your project root (or `~/.npmrc` for a global default): ```ini registry=https://YOUR-PIER-HOST/registry/npm/ //YOUR-PIER-HOST/registry/npm/:_authToken=pier_npm_… always-auth=true ``` `always-auth=true` is harmless on npm 7+ and required for yarn 1 — keep it so the `.npmrc` works for both clients. ## Install ```bash # Public package — proxied from npmjs.org if upstream proxy is on npm install left-pad # Private package npm install @your-org/internal-lib # Specific dist-tag npm install @your-org/internal-lib@beta ``` ## Publish ```bash # package.json carries the version npm publish # Or pin the target without touching .npmrc npm publish --registry=https://YOUR-PIER-HOST/registry/npm/ ``` ## Login (browser flow) ```bash npm login --auth-type=web --registry=https://YOUR-PIER-HOST/registry/npm/ # → opens browser, authenticate in Pier panel (with 2FA) # → pier_npm_… token written to .npmrc automatically ``` The legacy `npm login` (without `--auth-type=web`) uses the CouchDB user creation flow — also supported. The web flow inherits 2FA from the panel; the CouchDB flow does not prompt for OTP. If you require 2FA on every token mint, disable CouchDB login in panel settings. ## Dist-tags ```bash npm dist-tag add @your-org/lib@1.2.0 beta npm dist-tag ls @your-org/lib npm dist-tag rm @your-org/lib beta ``` ## Deprecate ```bash npm deprecate @your-org/lib@1.0.0 "use 1.2.x" npm deprecate @your-org/lib@1.0.0 "" # un-deprecate ``` ## Unpublish ```bash npm unpublish @your-org/lib@1.0.0 npm unpublish @your-org/lib --force # whole package ``` ## Versions matrix | npm version | Status | Notes | |---|---|---| | 7 | ✓ | Works out of the box | | 8 | ✓ | Works out of the box | | 9 | ✓ | Works out of the box | | 10 | ✓ | Works out of the box | | 11 | ✓ | `always-auth` config warning is harmless | ## Common errors - **`401 Unauthorized` on install** — token missing or `always-auth=true` absent from `.npmrc`. See [Troubleshooting](/docs/registry/troubleshoot). - **`409 Conflict` on publish** — version already exists. Bump in `package.json`. - **`npm warn Unknown project config "always-auth"` on npm 11** — flag is deprecated but still honored. You can ignore the warning. ## Related - [Setup](/docs/registry/setup) — initial configuration walkthrough. - [Publish](/docs/registry/publish) — full publish workflow. - [CI integration](/docs/registry/ci) — npm in GitHub Actions and GitLab CI. --- ## Use pnpm with Pier > pnpm 9 and 10 talk to Pier through .npmrc. Workspaces inherit the registry from the workspace root. Drop-in replacement for npm install. Source: https://pier.team/docs/registry/clients/pnpm pnpm reads its registry config from **`.npmrc`** — the same file npm uses. Authentication semantics match npm (bearer on every request), so unlike yarn 1 you don't need `always-auth=true` for pnpm to work. ## Configure ```ini # .npmrc registry=https://YOUR-PIER-HOST/registry/npm/ //YOUR-PIER-HOST/registry/npm/:_authToken=pier_npm_… always-auth=true ``` `always-auth=true` is **optional** for pnpm — it's harmless and recommended if you also use yarn 1 in the same project. ## Install / add ```bash pnpm install pnpm add left-pad pnpm add @your-org/internal-lib pnpm add -D typescript # devDependency ``` ## Publish ```bash pnpm publish pnpm publish --tag beta pnpm publish --no-git-checks # skip the clean-tree check in CI ``` ## Workspaces For a pnpm workspace, put `.npmrc` at the workspace root — every package inherits it. No need to duplicate per package. ``` my-monorepo/ ├── .npmrc ← here ├── package.json ├── pnpm-workspace.yaml └── packages/ ├── app/package.json └── lib/package.json ``` If a single package needs a different registry, drop a `.npmrc` next to its `package.json` — pnpm uses the closest one walking up. ## The `.pnpm` symlink farm When you list `node_modules/` after a pnpm install, you'll see `node_modules/.pnpm/` containing the actual content-addressed store. Top-level deps are symlinks into it. This is intentional and doesn't affect Pier — packages are still requested through the same HTTP API. ## Versions matrix | pnpm version | Status | Notes | |---|---|---| | 9 | ✓ | Works out of the box | | 10 | ✓ | Works out of the box | Pin via `packageManager` in `package.json` so corepack uses the right version: ```json { "packageManager": "pnpm@10.0.0" } ``` ## Common errors - **`ERR_PNPM_FETCH_401`** — token revoked or missing. Mint a fresh one in **Packages → Manage tokens**. - **`ERR_PNPM_FETCH_404`** — upstream proxy off + package not privately published. Toggle it on in **Packages → Upstream proxy**. - **`ERR_PNPM_INTEGRITY_CHECK_FAILED`** — Pier-stored tarball got out of sync with its sha512. Rare; typically caused by manual FS edits. Solution: `rm /opt/pier/data/registry//.tgz` and re-install (Pier will re-fetch with correct integrity). ## Related - [Setup](/docs/registry/setup) — base walkthrough. - [CI integration](/docs/registry/ci) — pnpm in CI snippets. - [Troubleshooting](/docs/registry/troubleshoot) — more error scenarios. --- ## Use yarn 2 / 3 / 4 with Pier > yarn berry uses .yarnrc.yml and needs npmAlwaysAuth — the berry equivalent of always-auth=true. Works with yarn 2.x, 3.x and 4.x via corepack. Source: https://pier.team/docs/registry/clients/yarn-berry Yarn berry (yarn 2, 3 and 4) replaced `.npmrc` with **`.yarnrc.yml`** in 2019. The registry config moves there, and there's a quirk equivalent to yarn 1's `always-auth=true`: > **`npmAlwaysAuth: true` is mandatory.** Without it, yarn 4 only sends the bearer on `npm publish` — not on `npm install`. Same root cause as yarn 1; different config file. ## Configure `.yarnrc.yml` in the project root: ```yaml npmRegistryServer: "https://YOUR-PIER-HOST/registry/npm/" npmAuthToken: "pier_npm_…" npmAlwaysAuth: true nodeLinker: node-modules ``` `nodeLinker: node-modules` opts out of yarn berry's plug'n'play default and falls back to the familiar `node_modules` layout — recommended unless your toolchain explicitly supports PnP. `package.json` needs `packageManager` pinning so corepack picks the right binary: ```json { "name": "my-app", "version": "0.1.0", "packageManager": "yarn@4.5.1" } ``` ## Install / add ```bash corepack enable yarn install yarn add left-pad yarn add @your-org/internal-lib ``` Corepack reads `packageManager` from `package.json` and runs that exact yarn version, downloading it on first use. ## Publish ```bash yarn npm publish --access public # scoped public yarn npm publish # private package ``` Note the `npm` namespace: berry's CLI subcommands sit under `yarn npm ` (publish, login, whoami, info, tag) to disambiguate from yarn's own commands. ## Login ```bash yarn npm login --scope=@your-org # Prompts for username + password + email + OTP ``` Or skip the interactive login and just put `npmAuthToken` in `.yarnrc.yml` directly — works the same. Token comes from **Packages → Manage tokens** in the Pier panel. ## Dist-tags ```bash yarn npm tag add @your-org/lib@1.2.0 beta yarn npm tag list @your-org/lib yarn npm tag remove @your-org/lib beta ``` ## Versions matrix | yarn berry | Status | Notes | |---|---|---| | 2.4.3 | ✓ | Tested in pier-tests matrix | | 3.8.7 | ✓ | Tested in pier-tests matrix | | 4.5.1 | ✓ | Tested in pier-tests matrix | All three berry majors share the same `.yarnrc.yml` shape — pin the version you want via `packageManager` in `package.json`, corepack does the rest. ## Common errors - **`YN0041: Invalid authentication (as an anonymous user)`** — `npmAlwaysAuth: true` missing. - **`YN0027: can't be resolved to a satisfying range`** — registry isn't reachable (typo in URL) or upstream proxy off and the package isn't private. - **`Corepack is about to download …`** — first run with a pinned version downloads the binary. Safe to confirm; or set `COREPACK_ENABLE_AUTO_PIN=0 corepack prepare yarn@4.5.1 --activate` beforehand. ## Scoped registry routing (optional) If you want one scope to go to Pier and everything else to npmjs.org without enabling upstream proxy on Pier, scope the registry: ```yaml npmScopes: your-org: npmRegistryServer: "https://YOUR-PIER-HOST/registry/npm/" npmAuthToken: "pier_npm_…" npmAlwaysAuth: true ``` Most teams just enable the proxy and route everything through Pier — but this works too. ## Related - [yarn 1.x](/docs/registry/clients/yarn-classic) — the .npmrc version. - [Setup](/docs/registry/setup) — the base flow + token mint. - [CI integration](/docs/registry/ci) — yarn berry in CI. --- ## Use yarn 1.x with Pier > yarn classic (1.22) talks to Pier through the same .npmrc as npm — but you must add always-auth=true or every GET returns 401. Source: https://pier.team/docs/registry/clients/yarn-classic Yarn classic — `yarn` 1.22 — reads its registry config from **`.npmrc`** just like npm. There's one critical quirk: > **`always-auth=true` is mandatory** for yarn 1. Without it, yarn 1 omits the bearer token on `GET` requests, and every install fails with `401 Unauthorized`. npm/pnpm/bun send the bearer by default, so this is yarn-1-specific. ## Configure ```ini # .npmrc registry=https://YOUR-PIER-HOST/registry/npm/ //YOUR-PIER-HOST/registry/npm/:_authToken=pier_npm_… always-auth=true ``` Yarn 1 also reads `.yarnrc` for some flags, but registry + auth come from `.npmrc`. ## Install ```bash yarn install yarn add left-pad yarn add @your-org/internal-lib ``` ## Publish ```bash yarn publish ``` Yarn 1 prompts for the version bump interactively. Pass `--new-version 1.2.0` to skip the prompt in CI. ## Login Yarn 1 doesn't have its own login flow — use `npm login` against Pier and the token lands in `.npmrc` (which yarn 1 reads). ```bash npm login --auth-type=web --registry=https://YOUR-PIER-HOST/registry/npm/ ``` ## Why `always-auth=true` matters Yarn 1 has a hard-coded `auth.optional = true` behavior for GET requests (which include packument fetches and tarball downloads). When `always-auth=true` is set, yarn 1 promotes auth to required on every request, including GETs. Pier requires auth on every authenticated route (including reads of private packages). Without `always-auth=true`, yarn 1 sends no bearer → Pier returns 401 → yarn fails. This setting is **harmless for every other client**, so it's safe to keep it on all the time. ## Node version warning Yarn 1.22.22 has a TLS handshake quirk on **Node 20** that produces a confusing `Request.emit` stack trace when hitting an HTTPS upstream. Use **Node 22+ (LTS 24 ideal)** if you're on yarn 1. ```bash nvm install 24 nvm use 24 yarn --version # → 1.22.22 yarn install # works ``` ## Versions matrix | yarn version | Status | Notes | |---|---|---| | 1.22.x | ✓ | `always-auth=true` mandatory; use Node 22+ | | 2.x / 3.x / 4.x | ✓ | Different config — see [yarn berry guide](/docs/registry/clients/yarn-berry) | ## Common errors - **`Request "https://…/registry/npm/foo" finished with status code 401`** — `always-auth=true` is missing. - **`Request.emit (events.js:223:5)` stack trace** — Node 20 TLS bug on yarn 1.22. Upgrade Node to 22+ or 24 LTS. - **`error An unexpected error occurred: "404 Not Found"`** — upstream proxy is off and the package isn't privately published. Enable proxy or publish privately. ## Related - [yarn berry (2/3/4)](/docs/registry/clients/yarn-berry) — different config file. - [Setup](/docs/registry/setup) — base configuration walkthrough. - [Troubleshooting](/docs/registry/troubleshoot) — more 401 scenarios. --- ## npm Registry — Overview > Pier's built-in npm-compatible registry — private packages plus a transparent npmjs.org mirror, served from one URL. Works with npm 7-11, yarn 1/2/3/4, pnpm and bun. Source: https://pier.team/docs/registry/overview ## TL;DR Pier ships a **full npm-compatible registry** as part of the same Rust binary that runs your containers. No Verdaccio container, no extra database — open `https://your-pier-host/registry/npm/`, mint a token from the panel, write three lines into `.npmrc`, and you have a private + proxy registry that the whole team can use. It speaks the **npm registry HTTP protocol** end-to-end, so npm, yarn (1 and 2/3/4), pnpm and bun all work against it without bespoke client code. ## What it does - **Private publishing.** `npm publish` (scoped and unscoped), `npm dist-tag`, `npm deprecate`, `npm unpublish`. The full set of standard mutations. - **Upstream proxy mode.** Mirror `registry.npmjs.org` (or any compatible upstream) — packuments cached forever, tarballs pulled lazily on the first install, LRU GC keeps the on-disk cache under a configurable cap. - **Web login flow.** `npm login --auth-type=web` opens the browser, authenticates against the Pier panel (with 2FA), and drops a long-lived `pier_npm_…` token into `.npmrc` — no manual copy-paste. - **Streamed downloads.** Tarballs are served via `axum::Body::from_stream` — Pier doesn't buffer a 200 MiB tarball in RAM. - **Rate-limited per-route.** Public reads, admin mutations and `npm publish` each have their own per-IP budget. ## Who it's for Pier's registry is a fit if you: - Want a **self-hosted alternative to Verdaccio** without the extra container and database. - Have a small team that wants **one `.npmrc` URL** for both private packages and public dependencies. - Need installs to **keep working when `npmjs.org` is down**. - Want **audit visibility** — see which public packages your team actually uses. - Already run Pier for containers and don't want to operate a second daemon. ## What's not in v1 - **`npm search`** — no `/-/v1/search` endpoint yet. Discovery is via the Pier UI. - **`npm owner` / RBAC** — single-owner model today: the publisher of a version controls deprecate / unpublish for that version, admin overrides. Multi-maintainer ownership lands later. - **`npm audit`** — the natural way to ship this is after the upstream-proxy work, so the audit payloads can be passed through to `npmjs.org`. Both are tracked on the roadmap. - **`npm star/unstar`** — explicitly not planned. The UI exposes a "pin" toggle for the Mirror tab, which solves the same problem better. Everything else from the standard npm CLI works against Pier today. ## Architecture at a glance ``` npm install left-pad ─┬─► GET /registry/npm/left-pad │ (packument, ETag-cached) │ └─► GET /registry/npm/left-pad/-/left-pad-1.3.0.tgz ↓ first time: upstream fetch + cache ↓ next time: stream from local FS ``` - Packument metadata lives in SQLite (`npm_packages` for the raw JSON blob, `npm_versions` for downloaded versions). - Tarballs live on the local filesystem at `data/registry/{package}/{file}.tgz`. Optionally mirrored to any S3-compatible cold tier (`Packages → S3 configure`). - ETag revalidation via `If-None-Match` → 304 short-circuit. TTL-based refresh wakes up stale entries automatically. ## Next steps 1. [Enable the registry and configure your first client](/docs/registry/setup) — 5 minutes. 2. [Turn on upstream proxy mode](/docs/registry/proxy) so the team mirrors `npmjs.org`. 3. Pick your client guide: [npm](/docs/registry/clients/npm) · [yarn 1.x](/docs/registry/clients/yarn-classic) · [yarn 2/3/4](/docs/registry/clients/yarn-berry) · [pnpm](/docs/registry/clients/pnpm) · [bun](/docs/registry/clients/bun). ## Related - Feature page: [Self-hosted npm registry](/features/npm-registry) — for the executive summary. - [Publish private packages](/docs/registry/publish) — scoped vs. unscoped, integrity, common pitfalls. - [CI integration](/docs/registry/ci) — GitHub Actions, GitLab CI snippets. --- ## Upstream proxy mode > Mirror npmjs.org through Pier — one URL for the team, lazy tarball cache, TTL revalidation and LRU eviction under a configurable size cap. Source: https://pier.team/docs/registry/proxy Upstream proxy mode turns Pier into a **transparent mirror** of `registry.npmjs.org` (or any npm-compatible upstream). The whole team uses one `.npmrc` URL and Pier handles the rest — packuments cached and revalidated, tarballs pulled lazily on first install, GC keeps disk usage under a cap. ## Why use it - **One URL, no scope routing.** Private and public packages live behind the same `https://your-pier-host/registry/npm/`. No more `@org:registry=…` plus a default registry — one line. - **Installs survive `npmjs.org` outages.** Anything you've installed before is in the cache. New transitive deps fail only on first miss. - **Audit visibility.** The `Mirror` tab lists every public package the team has installed through Pier, with size on disk and last-fetched time. - **Compliance.** If you need to vet which versions reach your codebase, the proxy is the chokepoint. ## How it works ``` Client Pier Upstream │ │ │ │ GET /react │ │ ├───────────────────►│ cache miss / TTL expired │ │ ├───────────────────────────►│ │ │ 200 packument │ │ │◄───────────────────────────┤ │ packument │ cache + URL-rewrite │ │◄───────────────────┤ dist.tarball → /registry/ │ │ │ │ │ GET /react/-/.tgz │ │ ├───────────────────►│ cache miss │ │ ├───────────────────────────►│ │ │ tarball bytes │ │ │◄───────────────────────────┤ │ tarball │ write FS + DB row │ │◄───────────────────┤ │ ``` - **Packument cache** lives in SQLite as a single JSON blob per package (`npm_packages.upstream_packument_json`). One row per package — even for `next` (3769 versions) that's still one row. - **Tarball cache** lives on disk at `data/registry/{package}/{file}.tgz`. Only versions clients actually installed land here. - **TTL revalidation** — when a cached packument is older than the configured TTL, Pier sends `If-None-Match: ` upstream. A `304` short-circuits without payload; a `200` replaces the blob. ## Configure **Packages → Upstream proxy** has four settings: | Setting | What it does | Reasonable values | |---|---|---| | **Enable upstream proxy** | Master toggle | Off by default; flip on | | **Upstream URL** | Where to fetch from | `https://registry.npmjs.org` (default) | | **Packument TTL (seconds)** | How often to revalidate metadata | 600 (10 min) default; 3600 if you want less chatter | | **Max cache size (MiB)** | Hard cap on tarball storage. `0` = unlimited | See sizing below | ### Sizing the cache The packument cache is small (~1–50 KB per package, all in SQLite — counts toward the DB file, not the on-disk cap). Tarballs dominate. | Scenario | Recommended cap | |---|---| | Single dev box | 500–1000 MiB | | Small team (3–5 devs) | 2–5 GiB | | Shared CI mirror | 20+ GiB | | Server with plenty of disk | `0` (unlimited) | When the cap is exceeded, the background LRU GC (runs every 10 minutes) drops the oldest tarballs until the total fits. Evicted tarballs are silently re-fetched on next install — the user sees no error, just an extra network round-trip. ## Inspect what's cached The **Mirror** tab in `Packages` lists every cached public package: - **Versions cached** — count from the upstream packument - **Tarballs on disk** — sum of actually-downloaded versions - **Last fetched** — when the packument was last refreshed - **★** — pin the packages you care about, then filter with **Pinned only** Click a row to open the package detail. Cached versions show their size, integrity hash, and a **Deprecate** / **Unpublish** button (operator-level operations). ## Pre-fetch a version When a proxy package has metadata but no tarballs on disk (post-GC, or a cold cache), the detail page shows a **Download latest (X.Y.Z)** button. Click it to eagerly pull that version's tarball through Pier without running `npm install` locally — useful for warming the cache or smoke-testing a new mirror. ## Disable temporarily Toggle **Enable upstream proxy** off and save. Cached packages stay in the DB and on disk; new requests for uncached packages return `404`. Re-enable to resume. Cache is never auto-cleared on disable. ## Failure handling If upstream is unreachable on a TTL revalidation, Pier serves the cached copy and logs a warning. The proxy never blocks reads on upstream availability. If upstream returns `404` on a refresh of a previously-cached package, Pier keeps serving the cached copy — explicit unpublish from your registry is a separate, more careful operation. ## Related - [Setup](/docs/registry/setup) — initial enable + token. - [Troubleshooting](/docs/registry/troubleshoot) — common errors when the proxy is on. - [CI integration](/docs/registry/ci) — using the proxy from build runners. --- ## Publish private packages > Publish scoped and unscoped private packages to Pier. Integrity checks, dist-tags, deprecate, unpublish — the full standard workflow. Source: https://pier.team/docs/registry/publish Pier accepts the standard `npm publish` flow for both **scoped** (`@org/name`) and **unscoped** (`name`) packages. The protocol is the CouchDB-style `PUT` with `_attachments` — same wire format every npm CLI sends to `npmjs.org`. ## Publish a scoped package ```bash # package.json { "name": "@your-org/my-lib", "version": "1.0.0", "publishConfig": { "registry": "https://YOUR-PIER-HOST/registry/npm/" } } ``` ```bash npm publish # published @your-org/my-lib@1.0.0 ``` The `publishConfig` is the standard way to pin one package's publish target without changing the global registry. If you don't set it, npm uses the `registry` from your `.npmrc`. ## Publish an unscoped package Same flow, no `@scope/`: ```bash # package.json { "name": "my-internal-lib", "version": "1.0.0" } ``` ```bash npm publish --registry=https://YOUR-PIER-HOST/registry/npm/ ``` > Unscoped private packages collide with the public namespace if you also have upstream proxy on. If `my-internal-lib` already exists on `npmjs.org`, Pier still accepts your publish, and your private version shadows the proxy entry. We recommend scoped names (`@your-org/…`) for clarity. ## Integrity verification If your CLI sends `dist.integrity` in the publish body, Pier compares it against the sha512 of the uploaded tarball and rejects mismatches with a 400. If not sent, Pier computes the integrity from the bytes and stores it. Either way every stored tarball has a verified sha512. ## Dist-tags ```bash npm dist-tag add @your-org/my-lib@1.2.0 beta npm dist-tag ls @your-org/my-lib # latest: 1.2.0 # beta: 1.2.0 npm dist-tag rm @your-org/my-lib beta ``` `latest` is the only required tag. You can have any number of named tags pointing at any version. ## Deprecate a version ```bash npm deprecate @your-org/my-lib@1.0.0 "use 1.2.x" ``` The deprecation message lands in `npm_versions.manifest_json.deprecated`, surfaces on the package detail page, and shows up in clients with `npm warn` on install. To un-deprecate, pass an empty message: ```bash npm deprecate @your-org/my-lib@1.0.0 "" ``` ## Unpublish ```bash # single version npm unpublish @your-org/my-lib@1.0.0 # whole package npm unpublish @your-org/my-lib --force ``` Pier deletes the version row (or the package row + all versions for a whole-package unpublish), removes the tarball from disk, and writes a tombstone so re-publishing the same name+version is rejected — matching npm policy. > Unpublish is the only destructive operation reachable via CLI. Be careful — there's no undo. ## Authentication for publish `npm publish` sends the bearer token from `.npmrc`: ```ini //YOUR-PIER-HOST/registry/npm/:_authToken=pier_npm_… ``` Or use `npm login --auth-type=web` to do the OAuth-style browser flow (with 2FA enforcement): ```bash npm login --auth-type=web --registry=https://YOUR-PIER-HOST/registry/npm/ # Opens the browser → authenticate in Pier panel → token in .npmrc ``` ## Common errors - **`E409 Conflict`** — you're re-publishing a version that already exists. Bump the version in `package.json`. (See [Troubleshooting](/docs/registry/troubleshoot) for the full list.) - **`401 Unauthorized`** — token missing or revoked. Mint a fresh one in **Packages → Manage tokens**. - **`400 attachment name mismatch`** — your CLI is sending an unexpected `_attachments` key. Most clients send `@scope/name-version.tgz` (full) or `name-version.tgz` (short) — Pier accepts both. ## Next steps - [CI integration](/docs/registry/ci) — automate publish from your build pipeline. - [Per-client guides](/docs/registry/clients/npm) — client-specific publish quirks. --- ## Enable the registry > Five-minute setup — mint a token, drop three lines into .npmrc, and start installing through Pier. Source: https://pier.team/docs/registry/setup The registry is enabled by default on every Pier install. You only need to: 1. Mint a personal token in the panel. 2. Drop a `.npmrc` (or `.yarnrc.yml` for yarn berry) into your project. 3. Optionally toggle upstream proxy on if you want public packages cached too. ## 1. Mint a token 1. Open the Pier panel and go to **Packages → Manage tokens**. 2. Click **New token**, give it a name like `laptop-2026`. 3. Pier shows the plaintext token **once** — copy it now. Format is `pier_npm_…`. > The token is bound to your panel account. Revoke it from the same screen whenever you want — every existing client using that token starts getting 401 immediately. ## 2. Configure your client The minimum `.npmrc` (works for npm, pnpm, bun and yarn classic with one extra line): ```ini registry=https://YOUR-PIER-HOST/registry/npm/ //YOUR-PIER-HOST/registry/npm/:_authToken=pier_npm_… always-auth=true ``` `always-auth=true` is required for **yarn 1.x** (which otherwise omits the bearer on GET) and harmless for npm/pnpm/bun. For yarn berry (2/3/4) the config file is different — see [Use yarn 2/3/4 with Pier](/docs/registry/clients/yarn-berry). ## 3. Verify it works ```bash curl https://YOUR-PIER-HOST/registry/npm/-/ping # {} ``` A successful `{}` response confirms the registry is reachable. Then: ```bash npm whoami # tester ``` If you get `401`, recheck the token and `always-auth=true`. If you get `503`, Traefik isn't up yet — give it 30 seconds after a fresh `bash install.sh` run. ## 4. (Optional) enable upstream proxy If you want to mirror `npmjs.org` through Pier so the team uses a single URL: 1. Open **Packages → Upstream proxy**. 2. Toggle **Enable upstream proxy** on. 3. **Upstream URL** defaults to `https://registry.npmjs.org` — keep it unless you're mirroring an internal NPM Enterprise instance. 4. **Packument TTL (seconds)** — 600 (10 min) is a sane default; bump it to 3600 if you want fewer upstream round-trips. 5. **Max cache size (MiB)** — `0` means unlimited (recommended on hosts with plenty of disk). Set 2–5 GiB for small teams; 20+ GiB for CI mirrors. 6. Click **Save**. The whole `Mirror` tab now lists every public package the team has installed through Pier. Star the ones you care about and filter with **Pinned only**. ## 5. Pick your client guide - [Use npm](/docs/registry/clients/npm) — npm 7 through 11. - [Use yarn 1.x](/docs/registry/clients/yarn-classic) — the always-auth quirk. - [Use yarn 2 / 3 / 4](/docs/registry/clients/yarn-berry) — the `.yarnrc.yml` setup. - [Use pnpm](/docs/registry/clients/pnpm) — workspaces tip. - [Use bun](/docs/registry/clients/bun) — abbreviated packument note. ## Next: publish or install - Want to publish a private package? Go to [Publish private packages](/docs/registry/publish). - Want CI to install through Pier? See [CI integration](/docs/registry/ci). - Hit a wall? [Troubleshooting](/docs/registry/troubleshoot) covers the common 401/409/304 cases. --- ## Registry troubleshooting > Common errors with Pier's npm registry — 401, 409, missing bearer on yarn 1, ETag confusion, abbreviated packument issues. Each with a one-line fix. Source: https://pier.team/docs/registry/troubleshoot ## 401 Unauthorized — even though I'm logged in Three common causes: 1. **`always-auth=true` missing from `.npmrc`** — yarn 1 refuses to send the bearer on GET without it. npm/pnpm/bun ignore the flag harmlessly, so just always add it. 2. **Token revoked** — check **Packages → Manage tokens** in the panel. If the row is gone, mint a fresh one. 3. **Wrong host in the auth line** — the line must use the exact host serving the registry, e.g. `//pier.example.com/registry/npm/:_authToken=…`. A trailing or missing slash mismatch causes silent 401. ## 401 specifically on yarn 4 Yarn berry needs `npmAlwaysAuth: true` in `.yarnrc.yml` (not in `.npmrc`): ```yaml npmRegistryServer: "https://YOUR-PIER-HOST/registry/npm/" npmAuthToken: "pier_npm_…" npmAlwaysAuth: true nodeLinker: node-modules ``` Without it yarn 4 only sends the bearer on `npm publish`, not on `npm install`. ## 409 Conflict on publish You're re-publishing a version that already exists. Bump the version in `package.json` (`npm version patch / minor / major`). Pier rejects re-publishes by design — it matches npm semantics. If you want to truly replace a published version, `npm unpublish @your-org/pkg@X.Y.Z` first, then `npm publish` the new bytes. Beware: external consumers who pinned `X.Y.Z` and have it in their lockfile will get integrity mismatches. ## ETag isn't short-circuiting (no 304) The packument response carries an ETag. If your client sends `If-None-Match` with that same value, Pier returns `304`. If you see full 200 responses every time, check: - **Auth header is the same as before.** Some HTTP libraries skip If-None-Match when the request changes (different Accept etc.) — make sure your `Accept` header is stable. - **Compression** — if the response was gzip'd and the client requests it uncompressed (or vice versa), the ETag is unchanged but some intermediaries strip the If-None-Match. Curl with `-H "Accept-Encoding: gzip"` to repro reliably. ## Abbreviated packument confusion Bun (and recent npm) sends `Accept: application/vnd.npm.install-v1+json` — Pier returns a leaner JSON (no README, no historical times). If your tooling expects the full packument, drop the abbreviated `Accept`. ## 502 Bad Gateway Traefik is up but Pier isn't reachable on its loopback port. Check `systemctl status pier` on the host. If pier is up, check that `proxy.platform_domain` matches the host you're hitting. ## ENOENT / 404 on a public package I just installed Upstream proxy may not be enabled. Check **Packages → Upstream proxy → Enable upstream proxy** — when off, only privately-published packages are served. If proxy is on and you still get 404, the upstream might have actually returned 404. Pier passes that through (a package that genuinely doesn't exist on `npmjs.org` should be a 404). ## "Tarballs on disk" shows 0 but I just installed LRU GC may have evicted them. The cache cap is in **Packages → Upstream proxy → Max cache size (MiB)** — if it's set to a small value (e.g. 50) and you installed something big like Next.js, the older tarballs get reclaimed. Set Max cache size to `0` (unlimited) if you have plenty of disk. You can also re-fetch a specific version from the package detail page — the **Download latest (X.Y.Z)** button pulls a single tarball through Pier without firing up an `npm install`. ## Detail page shows "0 versions" for a known cached package The packument blob may be empty (typically right after a migration that wiped per-version rows). Pier auto-refreshes the blob on detail page load if it's empty — the first visit might be slow (~1s) while it fetches from upstream. Subsequent visits are instant. If it stays at 0 after a reload, upstream is either unreachable or returned 404. Check `journalctl -u pier | grep proxy`. ## "always-auth" warning on npm 11 ``` npm warn Unknown project config "always-auth" ``` npm 11 deprecated the flag but still respects it. You can ignore the warning. When npm 12 lands and removes it, yarn 1 will be a separate problem (most teams have moved on by then). ## Related - [Setup](/docs/registry/setup) — start here if you're new. - [Per-client guides](/docs/registry/clients/npm) — the right config file per client. - [Upstream proxy](/docs/registry/proxy) — for cache-specific issues. --- ## API tokens & CI deploy > Create Bearer API tokens for programmatic access and trigger deploys from CI. Source: https://pier.team/docs/security/api-tokens Session cookies are for browsers. For scripts, CLI tools, and CI runners — anything that can't carry a cookie — Pier issues **Bearer API tokens**. A token authenticates as the user who created it and inherits that user's roles, so a CI token can only deploy services its owner could deploy. Tokens are stored as a SHA-256 hash; the plaintext is shown **once** at creation and is never recoverable. ## Creating a token From the Account area (or directly against the API, with a session): - **Create** — `POST /api/v1/account/tokens` with a `name`. The response includes the `token` plaintext exactly once. Copy it now. - **List** — `GET /api/v1/account/tokens`. Returns each token's `id`, `name`, `prefix`, and `last_used_at` — never the secret. - **Revoke** — `DELETE /api/v1/account/tokens/{id}`. Idempotent; revocation takes effect immediately on the next request. Every issued token carries the `pier_npm_` prefix, so a leaked token is recognisable at a glance. Send it in an `Authorization: Bearer` header: ``` Authorization: Bearer pier_npm_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx ``` > Treat the token like a password. Anyone holding it acts as you, scoped to your roles. Store it in your CI provider's secret store, never in the repo. ## Triggering a deploy from CI Two authenticated deploy endpoints accept a Bearer token. Both run the same pipeline and return a `deployment_id` you can poll. | Endpoint | Use when CI knows… | | --- | --- | | `POST /api/v1/services/{id}/deploy` | the service's id | | `POST /api/v1/projects/{id}/services/{name}/deploy` | the project id and service name | Authorization requires project `Editor` on the target service (the token owner's project role). The service must already have its git repo configured. Both endpoints are rate-limited per client IP, since a deploy is an expensive clone-and-build. The JSON body is optional. Useful fields: `commit_sha` (recorded for traceability), `branch` (or `ref` as an alias), and `message`. If you omit the branch, Pier uses the service's configured branch, falling back to `main`. ```bash curl -X POST https://pier.example.com/api/v1/projects/proj_abc123/services/web/deploy \ -H "Authorization: Bearer pier_npm_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \ -H "Content-Type: application/json" \ -d '{ "commit_sha": "'"$GIT_COMMIT"'", "branch": "main", "message": "CI deploy from pipeline #42" }' ``` A successful response returns the deployment id and a status URL: ```json { "ok": true, "deployment_id": "d1e2f3...", "service_id": "svc_...", "status_url": "/api/v1/resources/svc_.../deployments/d1e2f3..." } ``` Poll `GET /api/v1/resources/{id}/deployments/{deployment_id}` until the build reaches a terminal state to make your pipeline wait on the result. ## Next steps - [Roles & access control](/docs/security/rbac) - [Two-factor auth](/docs/security/two-factor) - [Server hardening](/docs/security/hardening) --- ## Env-var encryption > How Pier encrypts environment variables at rest with AES-256-GCM. Source: https://pier.team/docs/security/env-encryption ## The design Every environment variable a service uses is stored in the `services.env_json` column. Before writing, Pier encrypts the JSON with AES-256-GCM using a 32-byte key from `/opt/pier/.env`: ``` PIER_SECRET=<32 bytes base64> ``` Encrypted values carry an `ENC:` prefix so Pier can distinguish them from legacy plain-text rows. ## Why the key lives outside the database If an attacker exfiltrates `pier.db` alone, they get nothing — the ciphertext is useless without the key. If an attacker exfiltrates only `.env`, there is no data to decrypt. An attacker needs **both** to read secrets. This is the same model Coolify uses (Laravel `APP_KEY` + encrypted cast). Simpler than a Vault, and appropriate for a single-tenant self-hosted tool. ## First-boot bootstrap On first run, if `/opt/pier/.env` does not exist, Pier generates a fresh `PIER_SECRET` and writes it with `chmod 600`. A one-time snapshot of the database is saved as `pier.db.pre-encryption` in case you need to roll back to plain-text mode for debugging. ## Rotation Rotation is rare. When needed: 1. Stop Pier. 2. Run the rotation helper (planned): `pier rotate-secret`. 3. Start Pier. Until the CLI helper ships, export env vars through the UI, rotate `PIER_SECRET` manually, and re-import. ## What is **not** encrypted - Service names, images, ports, and domains — these are not secrets. - Deployment metadata and logs — surface data, not credentials. - The Traefik dynamic config files — they contain hostnames, not secrets. --- ## Server hardening > A minimal hardening checklist for a Pier host. Source: https://pier.team/docs/security/hardening ## Basic hardening - Disable password SSH login; allow key-based only. - Run `ufw allow 22,80,443/tcp && ufw enable` to close everything else. - Keep the dashboard port (8443) firewalled off from the public internet. Access via SSH tunnel or a WireGuard/AmneziaWG VPN. - Enable automatic security updates: `apt install unattended-upgrades`. - Run the Pier systemd unit with the shipped hardening flags (`NoNewPrivileges=yes`, `ProtectSystem=strict`, `ProtectHome=yes`, `PrivateTmp=yes`). ## Filesystem permissions Pier installs with sensible defaults: ``` /opt/pier/bin/pier root:root 0755 /opt/pier/.env pier:pier 0600 /opt/pier/data/pier.db pier:pier 0600 /opt/pier/data/backups pier:pier 0700 ``` If you run `install.sh` yourself, preserve those modes. ## Docker daemon - Keep Docker up to date — `apt update && apt upgrade docker-ce`. - Avoid mounting `/var/run/docker.sock` into containers you don't fully trust. - Do not run containers with `--privileged` unless a template specifically requires it. None of Pier's first-party templates need it. ## Inbound secrets When you paste secrets into the Pier UI, they travel over HTTPS to the Pier API, are encrypted with AES-256-GCM, and written to SQLite. They never leave the server. --- ## Security overview > How Pier handles secrets, authentication, and server hardening. Source: https://pier.team/docs/security/overview Pier's threat model assumes: - The operator controls the server and the Pier binary. - The operator wants secrets safe even if the SQLite file leaks. - The dashboard is on the public internet, behind Traefik TLS. ## What Pier does - **Encryption at rest** — every environment variable is encrypted with AES-256-GCM before being stored. The key comes from `PIER_SECRET` (systemd reads it from `/opt/pier/.env`); if that variable is unset, Pier persists and reuses a `.pier-secret` file in the data directory. Either way the key lives separately from the database. - **Opaque cookie sessions with bcrypt passwords** — login issues a random, opaque session ID stored in the SQLite `sessions` table (not a JWT), so logout and revocation invalidate it server-side. Passwords are hashed with bcrypt. No third-party auth dependencies. - **Two-factor auth (TOTP)** — optional TOTP-based 2FA with one-time recovery codes. - **Session hardening** — sessions expire after an idle timeout (8 h default) and an absolute maximum lifetime (72 h), and can be revoked individually or all at once. - **No secret echoing** — API responses never return decrypted env vars except through an explicit `GET /env` endpoint. - **Dependency detection on the backend** — the Canvas view computes service relationships on the server so the browser never sees env-var values. - **Permissions** — `.env` and `pier.db` are `chmod 600`. - **Daily backups** — the database and key are snapshotted to `data/backups/system/`; 7 rolling copies kept. ## What the operator must do - Open only ports 80, 443, and 8443 on the firewall. - Use a strong admin password on first setup. - Keep the `/opt/pier/.env` file (and the `.pier-secret` fallback in the data dir, if present) out of any repository. Back it up separately. - Rotate `PIER_SECRET` only if the old key is compromised — rotation requires re-encrypting all rows. - Consider running the dashboard behind a VPN instead of exposing port 8443 publicly. Pier supports this (bind the dashboard to `127.0.0.1` and access it via SSH tunnel or a WireGuard/AmneziaWG link). ## Reporting vulnerabilities Send details to the address in `/.well-known/security.txt`. We respond within 72 hours and publish acknowledgements with each release. --- ## Roles & access control > How Pier's global roles and project-scoped roles decide who can do what. Source: https://pier.team/docs/security/rbac Pier's authorization model has two scopes. Every user carries one **global role**, and a user can additionally hold a **project role** on each project they're a member of. Most endpoints check one or the other; a few high-impact ones are reserved for the Owner. ## Global roles A global role is stored on the user record and travels with every authenticated request. There are three, ordered from most to least privileged. | Role | What it can do | | --- | --- | | `Owner` | Everything `Admin` can, plus the Owner-only knobs: changing other users' global roles, deleting/promoting Admins, federation grants, and the WireGuard mesh. The first user created at setup is the Owner. At least one active Owner is always required. | | `Admin` | Manage users (invite, edit, remove non-Owners), manage servers, view system metrics and the audit log, create projects, and operate Docker-level resources (containers, images, compose stacks, networks, registries). Bypasses project membership — an Admin reaches every project. | | `User` | A regular member. Sees only the projects they're an explicit member of, plus read-only global endpoints (server list, system metrics). Cannot create projects or manage users. | > Global `Owner` and `Admin` both bypass project-membership checks — they're treated as project `Admin` on every project. ## Project roles A project role is granted via project membership (the `project_members` table) and scopes a `User`'s reach to a single project without giving them global power. There are three. | Role | What it can do within the project | | --- | --- | | `Admin` | Everything `Editor` can, plus manage the project's membership (add/remove members, change their roles). | | `Editor` | Deploy and redeploy services, change env vars, domains and settings, restart. | | `Viewer` | Read-only: configs, logs, metrics, and deployment history. | Each project keeps at least one project `Admin`: Pier refuses to demote or remove the last one. ## How endpoints are gated Two mechanisms enforce roles: - **Router-level global guards.** Whole groups of routes sit behind a middleware layer. `require_global_admin` gates user management, Docker daemon ops, compose stacks, registries, the audit log, and server mutations. `require_global_owner` gates global-role changes (`PUT /users/{id}/role`), the mesh, and federation grants. - **In-handler project checks.** Project-scoped routes (resources, deployments, env, backups) call `enforce_project_role` / `enforce_resource_role` at the top of the handler, resolving the resource to its project and requiring a minimum project role. A concise map of common actions: | Action | Minimum role | | --- | --- | | Browse the catalog / view a project's services | `Viewer` (project) | | Deploy or redeploy a service, edit env/domains | `Editor` (project) | | Add or remove a project member | `Admin` (project) | | Create a new project | `Admin` (global) | | Invite or remove users, view audit log | `Admin` (global) | | Promote a user to `Admin`, or change any global role | `Owner` (global) | | Manage federation grants or the WireGuard mesh | `Owner` (global) | ## Next steps - [API tokens & CI deploy](/docs/security/api-tokens) - [Two-factor auth](/docs/security/two-factor) - [Security overview](/docs/security/overview) --- ## Two-factor auth > Enroll TOTP-based two-factor authentication with recovery codes and understand the two-step login flow. Source: https://pier.team/docs/security/two-factor Pier supports optional **TOTP** (time-based one-time password) two-factor auth, compatible with any authenticator app — Google Authenticator, Aegis, 1Password, and others. Enabling it adds a second step to your login: after your password, you present a 6-digit code or a one-time recovery code. 2FA is enrolled per user from the Account area. It does not change how sessions work — once you pass the second factor, you get the same opaque cookie session as a password-only login. ## Enrollment Enrollment is a two-call round-trip so the secret is never stored until you prove you can generate a valid code. 1. **Start setup** — `POST /api/v1/account/2fa/setup`. Pier returns a fresh base32 `secret`, an `otpauth_url`, an inline `qr_svg`, and **10 one-time recovery codes**. Nothing is persisted yet. 2. **Scan the QR** (or type the secret manually) into your authenticator app. The issuer shown is `Pier`. 3. **Save your recovery codes.** They are shown only once. Each looks like `XXXX-XXXX-XXXX` and works exactly once. 4. **Verify** — `POST /api/v1/account/2fa/verify` with the `secret`, the current 6-digit `code`, and the recovery hashes. On a correct code, Pier encrypts the secret (AES-256-GCM, same key as your env vars) and commits it. A wrong code leaves 2FA off. > The TOTP code is verified with a ±1 step (±30 s) window, so a small clock drift on your phone is tolerated. You can re-check enrollment state any time with `GET /api/v1/account/2fa`, which reports `enabled`, `enabled_at`, and `recovery_codes_remaining`. ## The login flow with 2FA When 2FA is enabled, login becomes two steps: | Step | Endpoint | You send | You get back | | --- | --- | --- | --- | | 1 | `POST /api/v1/auth/login` | username + password | `requires_2fa: true` and a short-lived `partial_token` | | 2 | `POST /api/v1/auth/login/2fa` | `partial_token` + `code` | the session cookie | The `partial_token` is **not** a session — it only proves you cleared the password step. It is a one-shot token held in memory, expires after **5 minutes**, and is bound to your IP. If pier-core restarts in between, retype your password. In step 2, the `code` field accepts **either** your current TOTP code **or** one of your recovery codes. A recovery code is consumed (removed) on use, so it never works twice. Both login endpoints are rate-limited to slow brute-force attempts against the 6-digit code. ## Recovery codes Recovery codes are your fallback when you lose your authenticator device. - 10 are generated at enrollment; only their hashes are stored. - They are case- and dash-insensitive on entry (`abcd-EFGH-1234` matches `ABCDEFGH1234`). - Each is single-use. When `recovery_codes_remaining` reaches zero, disable and re-enroll 2FA to get a fresh set. ## Disabling 2FA `POST /api/v1/account/2fa/disable` turns 2FA off, but it requires a valid current TOTP code **or** a recovery code in the request body. This prevents someone who has hijacked a live session from silently weakening your account. On success, the secret and recovery codes are cleared. To rotate your secret (for example, after moving to a new phone), disable 2FA and enroll again — Pier refuses to re-issue a secret while 2FA is still active. ## Next steps - [Roles & access control](/docs/security/rbac) - [API tokens & CI deploy](/docs/security/api-tokens) - [Security overview](/docs/security/overview) --- ## Troubleshoot > Common problems and how to diagnose them. Source: https://pier.team/docs/troubleshoot/overview ## Where to look first ```bash # systemd status sudo systemctl status pier # logs (last 100 lines) sudo journalctl -u pier -n 100 --no-pager # live tail sudo journalctl -u pier -f ``` ## Common symptoms ### "Port 80 already in use" Another service (Apache, nginx, a different Traefik) is binding port 80 on the host. Stop it or move it. Pier expects Traefik to own 80 and 443. ### Let's Encrypt fails to issue a cert - The domain's A record must resolve to this server **before** you add the domain in Pier. - Port 80 must be open to the public internet — ACME HTTP-01 challenges need it. - Check Traefik logs: `docker logs pier-traefik`. ### Container starts, then exits immediately - Open the **Logs** tab on the service. Most image issues show up in the container's stdout. - Confirm env vars are set. Missing required env is the most common cause. - For databases, check disk space on the data volume: `df -h`. ### Dashboard shows 502 - The Pier systemd unit is probably not running. `sudo systemctl restart pier`. - If Pier is running but returning 502, check `journalctl -u pier -n 200` for panics. ### High memory usage A single Pier process should stay under 50 MB resident. If you see hundreds of MB, one of the managed containers is using it. Open the **Memory** popup on the dashboard — it breaks memory down per container. ## Reporting bugs File issues at the [Pier repo](https://github.com/joveptesg/pier/issues) with: - Pier version (Settings → Updates). - OS + Docker version. - 100 lines of `journalctl -u pier`. - Steps to reproduce. ---