Scaffold, run, and deploy Rust web apps to your own VPS.
Early preview. Perc is in active development and has rough edges. APIs will change, features will break, and there are gaps in what's implemented. Don't rely on it for anything serious yet. See the website for more context.
cargo install perc
Or build from source:
git clone https://github.com/crabby-utils/perc.git
cd perc
cargo install --path .
perc new <name> # scaffold a new Rust+Axum project
perc status # show project status
perc --help # see all commands and flags
perc new myapp
cd myapp
perc dev
Creates a directory with a ready-to-run Rust+Axum app, perc.toml config, and .gitignore. The app reads the PORT environment variable (default 8080) and serves a hello-world route.
Start the development environment with file watching:
perc dev
This reads perc.toml and:
- Starts any configured service containers (PostgreSQL, RustFS, Restate) via Docker or Podman
- Finds available ports and runs
cargo runwith service connection environment variables - Watches
src/for changes and restarts the app automatically - Ctrl+C stops the app but leaves service containers running for fast restart
Services are only started when declared in perc.toml:
[database]— PostgreSQL 18 on port 5432[storage]— RustFS (S3-compatible) on ports 9000 (S3 API) and 9001 (console)[restate]— Restate on ports 8080 (ingress) and 9070 (admin)
If [restate] is configured, both the main app and the worker binary run simultaneously on separate auto-allocated ports. The worker is registered with Restate automatically (and re-registered after each restart).
Manage service containers:
perc dev status # show running services and ports
perc dev stop # stop containers (data preserved)
perc dev reset # stop and remove containers and volumes
Requires Docker or Podman with the daemon running.
Add S3-compatible object storage for local development:
[storage]
bucket = "my-bucket"This starts a RustFS container and auto-creates the bucket. The app receives:
| Variable | Value |
|---|---|
S3_ENDPOINT |
http://localhost:9000 |
S3_ACCESS_KEY |
percdev |
S3_SECRET_KEY |
percdevsecret |
S3_BUCKET |
bucket name from config |
The RustFS web console is available at http://localhost:9001 (login: percdev / percdevsecret).
| Variable | Source | When |
|---|---|---|
PORT |
Auto-allocated | Always |
DATABASE_URL |
Auto-generated | [database] present |
S3_ENDPOINT, S3_ACCESS_KEY, S3_SECRET_KEY, S3_BUCKET |
Fixed dev values | [storage] present |
RESTATE_INGRESS_URL |
http://localhost:8080 |
[restate] present |
All [env] values |
perc.toml |
Always |
Store credentials and settings in ~/.config/perc/credentials.toml (0600 permissions):
perc config set tailscale.authkey tskey-auth-...
perc config get tailscale.authkey
Environment variables override file values — TAILSCALE_AUTHKEY overrides tailscale.authkey. Use PERC_CONFIG_DIR to override the config directory location.
Bootstrap a fresh Ubuntu VPS for deployment:
perc deploy init <host>
This connects to <host> as root over SSH, then:
- Updates system packages
- Installs Tailscale and joins your tailnet (requires auth key — see above)
- Installs Podman 5.0+
- Installs perc-stats for VPS monitoring (accessible via Tailscale)
- Locks down SSH (password auth disabled, port 22 restricted to Tailscale interface)
- Configures UFW firewall (allows 80/443 for web traffic)
- Creates a dedicated
percdeploy user with a scoped sudoers policy - Verifies connectivity over Tailscale as the
percuser - Records the target in
perc.toml
After init, the host is only reachable via Tailscale. All subsequent commands connect as the perc user (not root), with sudo restricted to specific binaries only.
To deploy a second app to an already-initialized VPS, add it as a target in the new project:
perc deploy add my-vps # replace with your Tailscale machine name
This connects via Tailscale SSH, verifies connectivity, and records the target in perc.toml. Use this instead of init when the host is already bootstrapped.
Build, ship, and start the app on a target:
perc deploy push
This:
- Cross-compiles the Rust app for Linux (
x86_64-unknown-linux-musl) usingcargo-zigbuild - Builds a minimal OCI image in pure Rust (no local container runtime needed) — just the static binary, nothing else
- Pipes the image to the target via
ssh podman load(no registry needed) - Registers the app in the VPS-side registry and allocates a port
- Generates a Caddyfile with reverse proxy blocks for all deployed apps
- Deploys the app as a Podman Quadlet (systemd-managed container)
- Verifies the app responds on the target
Prerequisites:
- cargo-zigbuild:
cargo install cargo-zigbuild - A target already bootstrapped with
perc deploy init(or added withperc deploy add)
Use --target <name> to push to a specific target. Without it, pushes to the first configured target.
Deploy commands acquire a server-side lock to prevent concurrent deploys from corrupting state. If a previous deploy crashed and left a stale lock (auto-expires after 30 minutes), use --force to clear it:
perc deploy --force push
Each perc project has its own perc.toml and deploys independently. When you push multiple apps to the same VPS, perc automatically:
- Assigns each app a unique port (starting from 8080)
- Generates a shared Caddyfile with one block per app
- Routes traffic by domain (each app needs its own domain for HTTPS)
A single app without a domain gets a :80 fallback. Once you have multiple apps, assign domains to disambiguate.
Use perc deploy status from any project targeting the VPS to see all deployed apps.
Associate a domain with a target for automatic HTTPS:
perc deploy domain example.com
This:
- Saves the domain in
perc.tomlunder the target - Updates the app's domain in the VPS registry
- Regenerates the Caddyfile for all apps on the target
- Reloads Caddy, which automatically provisions a Let's Encrypt TLS certificate
Make sure the domain's DNS A record points to the server's public IP before running this command. The app must have been pushed at least once before setting a domain.
Use --target <name> to set the domain for a specific target.
Add a PostgreSQL database to a deployed app:
perc deploy db
This:
- Installs PostgreSQL on the VPS if not already present and auto-tunes it for available RAM (25% budget)
- Creates a dedicated database and user for the app
- Injects
DATABASE_URLinto the container environment (sqlx-compatible format) - Restarts the container
After running, the app can connect using the DATABASE_URL environment variable. Removing an app with perc deploy remove also drops its database and user.
Perc provisions the database but does not run migrations — that's your app's responsibility. The recommended approach is to run migrations at startup:
sqlx::migrate!().run(&pool).await?;This ensures the schema is always up to date after each deploy, with no extra commands or tooling required.
To have the database provisioned automatically on every push, add a [database] section to perc.toml:
[app]
name = "myapp"
[database]With this section present, perc deploy push ensures PostgreSQL is installed and the database exists before deploying. Credentials are stored in the VPS registry, not in perc.toml.
Multiple apps on the same VPS share a single PostgreSQL instance but each gets its own database and user with a unique password.
By default PostgreSQL listens only on localhost and is unreachable from outside the VPS — apps connect over the loopback interface. To let a tool on another tailnet machine (e.g. a CLI on your laptop) connect directly, grant it access with a dedicated remote role:
perc deploy db remote allow 100.99.232.19
100.99.232.19 is the client's Tailscale IP (run tailscale ip -4 on that machine); a tailnet device name also works. By default the role gets read-write access to the current project's database — pass --db <name> to target a different app's database, --role <name> to name the role, and --readonly for SELECT-only access.
This applies defense in depth so no single misconfiguration exposes the database:
- Binds PostgreSQL to
localhostplus the host's Tailscale IP only (never0.0.0.0), via a dedicatedconf.ddrop-in. - Adds a
pg_hba.confrule allowing only the client's/32withscram-sha-256. - Opens port 5432 only on the
tailscale0interface, only from that/32— the public interface stays closed. - Installs a systemd drop-in so PostgreSQL waits for
tailscaled(After=tailscaled.service network-online.target), so binding to the Tailscale IP can't lose a boot race.
The command prints a ready-to-use DATABASE_URL over Tailscale MagicDNS:
postgresql://<role>:<password>@<host>.<tailnet>.ts.net:5432/<db>
The first grant on a host requires a one-time PostgreSQL restart to pick up the new bind address (a brief connection drop; running apps reconnect automatically). Subsequent grants only reload.
List or revoke grants:
perc deploy db remote list
perc deploy db remote revoke <role>
Revoking drops the role, removes its pg_hba rule, and closes its firewall rule. These changes are recorded in the VPS registry and live in files perc's normal deploy flow never rewrites, so they persist across perc deploy push. Managing remote access connects to the VPS as root over Tailscale SSH (the same access perc deploy init uses), since it configures the firewall and PostgreSQL startup.
Tailscale ACLs: for an extra layer, you can also restrict
:5432to specific devices in your tailnet policy file. perc does not manage the tailnet ACL (it lives in the Tailscale admin console), so this step is optional and manual.
Add Restate support for durable workflows by adding a [restate] section to perc.toml:
[app]
name = "myapp"
[restate]
worker = "myapp-worker"The worker field names the Cargo binary that serves Restate endpoints. If omitted, it defaults to {app_name}-worker. Your Cargo project should declare a second binary:
[[bin]]
name = "myapp-worker"
path = "src/worker.rs"When [restate] is present, perc deploy push:
- Cross-compiles both the main server and worker binaries
- Builds separate OCI images for each
- Installs Restate server on the VPS if not already present (shared across apps, runs as a systemd service)
- Deploys the worker as a separate container with its own allocated port
- Registers the worker with Restate (
restate deployments register --force) - Injects
RESTATE_INGRESS_URLinto both containers so they can invoke Restate handlers
The Restate server uses port 9080 for ingress (instead of the default 8080, to avoid conflicts with app ports) and 9070 for admin.
Both the main app and worker containers use Network=host so they can communicate with Restate on localhost. The worker is also restarted when secrets change via perc deploy secret set/unset.
Removing an app with perc deploy remove also stops and removes the worker container.
Bundle extra files or directories into the container alongside the binary:
[app]
name = "myapp"
include = ["prompts", "static/config.json"]Each entry is copied into the container at the same relative path. Directories are included recursively. The binary runs with / as its working directory, so prompts/expand-base.md in your project becomes /prompts/expand-base.md in the container.
After deploying, perc waits for the app to become reachable by curling it over
the loopback interface on the VPS. It treats any HTTP response curl doesn't
consider an error (status < 400, including redirects) as "alive".
By default it probes /. If your app gates / behind auth — answering an
anonymous request with a redirect or a 401 — that still counts as reachable. But
to keep the intent explicit, expose a dedicated endpoint that returns 200 with
no auth and point the probe at it:
[app]
name = "myapp"
health_check = "/ready"The path must be absolute (start with /).
Manage non-secret environment variables in perc.toml with perc env:
perc env set S3_REGION=us-east-1 S3_BUCKET=mybucket
perc env unset S3_REGION
perc env list
This updates the [env] table in perc.toml:
[env]
S3_REGION = "us-east-1"
S3_BUCKET = "mybucket"
S3_ENDPOINT = "https://s3.amazonaws.com"These are injected as Environment= directives in the container on every push. Safe to commit to version control.
For secrets (API keys, passwords), use perc deploy secret to store them on the VPS:
perc deploy secret set S3_ACCESS_KEY=AKIAIOSFODNN7EXAMPLE S3_SECRET_KEY=wJalrXUtnFEMI/K7MDENG
perc deploy secret unset S3_SECRET_KEY
perc deploy secret list
perc deploy secret list --reveal
secret set and secret unset update the VPS registry and restart the container immediately. Secrets persist across pushes — they are stored in the VPS registry (/var/lib/perc/apps.toml), never in perc.toml or version control.
secret list masks values by default to prevent accidental exposure in CI logs or screen sharing. Use --reveal to show full values.
When both sources define the same key, the VPS secret takes precedence over the perc.toml value. DATABASE_URL (managed by perc deploy db) takes precedence over both.
The app must be deployed before setting secrets. Use --target <name> to manage secrets for a specific target.
perc deploy status
Connects to the target and displays all deployed apps, their ports, and domains. Use --target <name> to query a specific target.
Show recent logs for the deployed app:
perc deploy logs
By default, shows the last 50 lines. Use --lines / -n to control how many:
perc deploy logs -n 200
Stream logs in real time (like tail -f):
perc deploy logs --follow
perc deploy logs -f
Use --target <name> to view logs from a specific target. Press Ctrl+C to stop following.
Open the perc-stats dashboard for a target:
perc deploy monitor
This opens the monitoring UI in your browser (accessible over Tailscale only). Shows live CPU, memory, disk, network, and per-container metrics.
Use --target <name> to monitor a specific target.
perc deploy remove [name]
Removes an app from the target: unregisters it, regenerates the Caddyfile, stops the container, and removes the Quadlet unit. Defaults to the current project's app name if no name is given. Use --target <name> to remove from a specific target.
--target <name>— select deploy target--json— machine-readable JSON output-v/-vv/-vvv— increase log verbosity
Create a perc.toml in your project root:
[app]
name = "myapp"
include = ["prompts", "static/config.json"] # optional — files/dirs bundled into the container
health_check = "/ready" # optional — path the post-deploy probe hits (default "/")
[env] # optional — non-secret environment variables injected into the container
S3_REGION = "us-east-1"
S3_BUCKET = "mybucket"
[database] # optional — provisions a PostgreSQL database on push
[storage] # optional — S3-compatible storage via RustFS (local dev only)
bucket = "my-bucket"
[restate] # optional — installs Restate and deploys a worker binary
worker = "myapp-worker" # defaults to "{app_name}-worker" if omitted
[targets.production]
host = "example.com"
domain = "myapp.example.com"To contribute to perc itself:
cargo fmt --check
cargo clippy -- -D warnings
cargo test