Docs
Docker
Run the Proa image yourself, and own everything a platform would have done.
Proa produces one release binary and a public/ directory. This page packages both into an image and runs it on hardware you control.
Read what self-managed Docker costs you first. Take this path when you are running Proa beside an existing app, deploying into Kubernetes, Nomad, or ECS, or working somewhere a managed platform is not an option. For a plain public site, Railway or Coolify does all of this for you.
Build the image
Multi-stage: install the Proa CLI beside the Rust toolchain, build through the linked pipeline, run from a slim base. The GitHub token that fetches Proa's git dependencies stays in the build stage, which never ships. proa new site --deploy docker writes this Dockerfile for you; the version below is the same shape, using the installer script for the CLI.
# syntax=docker/dockerfile:1.7
FROM rust:1.93-bookworm AS build
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates curl git \
&& rm -rf /var/lib/apt/lists/*
# Install a pinned, checksum-verified Proa CLI and the Tailwind binary.
ARG PROA_VERSION=0.1.1
RUN curl --proto '=https' --tlsv1.2 -fsSL https://proa.so/install.sh \
| PROA_VERSION=$PROA_VERSION PROA_INSTALL_DIR=/usr/local/bin PROA_TAILWIND=yes PROA_YES=1 sh
COPY . .
# Fetch Proa git dependencies through the git CLI. While the Proa repository
# is private, pass a GitHub token with read access as Docker secret
# `github_token`; drop the secret once the repository is public.
ENV CARGO_NET_GIT_FETCH_WITH_CLI=true
RUN --mount=type=secret,id=github_token \
--mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/usr/local/cargo/git \
set -eu; \
export GIT_CONFIG_COUNT=1; \
export GIT_CONFIG_KEY_0=credential.https://github.com.helper; \
export GIT_CONFIG_VALUE_0='!f() { if [ "$1" = get ] && [ -s /run/secrets/github_token ]; then printf "username=x-access-token\npassword=%s\n" "$(cat /run/secrets/github_token)"; fi; }; f'; \
proa build --locked --artifact-out /tmp/proa-app
FROM debian:bookworm-slim AS runtime
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates curl \
&& rm -rf /var/lib/apt/lists/*
COPY --from=build /tmp/proa-app /usr/local/bin/my-site
COPY --from=build /app/public ./public
ENV PROA_HOST=0.0.0.0
ENV PORT=3000
ENV RUST_LOG=info
EXPOSE 3000
CMD ["my-site"]
proa build runs the configured asset build and the linked RSJS pipeline, then copies the attested binary to --artifact-out. A plain cargo build --release skips the asset build and fails at link time on any crate with islands. The generated server reads PROA_HOST and PORT and serves public/ relative to its working directory, which is why the runtime stage sets WORKDIR /app and copies public/ beside it.
docker build --secret id=github_token,env=GITHUB_TOKEN -t my-site:$(git rev-parse --short HEAD) .
Tag with the commit SHA, never latest. Rollback means pointing at the previous tag. Without immutable tags you have nothing to point at.
Build in CI rather than on the production box. Rust release builds want CPU and RAM that your web server should be spending on requests, and it keeps the GitHub token off the host entirely.
Run it
services:
web:
image: ghcr.io/you/my-site:abc1234
restart: unless-stopped
environment:
PROA_HOST: 0.0.0.0
PORT: 3000
RUST_LOG: info
healthcheck:
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:3000/healthz"]
interval: 10s
timeout: 2s
retries: 3
start_period: 5s
ports:
- "127.0.0.1:3000:3000"
Bind to 127.0.0.1:3000, not 0.0.0.0:3000. Publishing to all interfaces bypasses the host firewall on most Docker installs and exposes the app directly.
Beside an existing app
This is the shape the Rails and Django / Flask migrations describe: Proa renders the routes you have moved, your existing app serves the rest, and one proxy routes between them.
services:
legacy:
image: ghcr.io/you/legacy-app:def5678
expose: ["8000"]
proa:
image: ghcr.io/you/my-site:abc1234
expose: ["3000"]
environment:
PORT: 3000
proxy:
image: caddy:2
ports: ["80:80", "443:443"]
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
example.com {
handle /rooms/* {
reverse_proxy proa:3000
}
handle {
reverse_proxy legacy:8000
}
}
Move one path prefix at a time. The proxy config is the migration progress bar.
TLS and the rest of the platform
Caddy handles certificates and renewal with no configuration, which is why it appears above. nginx works too, with certbot and a renewal timer you now maintain.
Either way, preserve forwarding headers or the app cannot see the real client:
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Real-IP $remote_addr;
Do not cache dynamic HTML at the proxy until you have route-level rules. See Page caching.
Three more things a platform would have handled, in the order they bite:
- Logs.
docker logsvanishes with the container. Ship to a collector before you need it, not after. - Disk. Old images accumulate silently. Run
docker image prune -af --filter "until=168h"on a weekly timer. - Alerting. The compose healthcheck restarts a wedged container. It does not tell you it happened.
Ship a new version without downtime
docker compose up -d stops the old container before the new one is ready. That is a visible outage on every deploy.
Start the new version alongside, prove it healthy, then move traffic:
NEW=abc1234
docker run -d --name my-site-$NEW -p 127.0.0.1:3001:3000 \
-e PORT=3000 -e PROA_HOST=0.0.0.0 ghcr.io/you/my-site:$NEW
for i in $(seq 30); do
curl -fsS http://127.0.0.1:3001/healthz && break
sleep 1
done || { docker rm -f my-site-$NEW; echo "unhealthy, aborting"; exit 1; }
# flip the proxy upstream 3000 -> 3001, reload, then retire the old container
Rollback is the same sequence with the previous SHA. Which is exactly why the tag has to be a SHA.
For more than one host, this is the point where Kubernetes, Nomad, or ECS stops being overkill.
Verify before you deploy
docker build --secret id=github_token,env=GITHUB_TOKEN -t my-site:test .
docker run --rm -p 3000:3000 -e PORT=3000 my-site:test
curl -fsS http://127.0.0.1:3000/healthz
curl -fsS http://127.0.0.1:3000/static/styles.css -o /dev/null
docker run --rm my-site:test env | grep -c GITHUB_TOKEN # expect 0
docker image inspect my-site:test --format '{{.Size}}' # expect ~100MB, not ~2GB
The last line catches a single-stage Dockerfile, which ships your entire Rust toolchain and source to production.
Next steps
- Deploy overview
- Pick a deployment target, then follow the guide for it.
- Railway
- Deploy a Proa app to Railway from a Dockerfile in about ten minutes.
- Coolify
- Deploy a Proa app to Coolify on your own VPS.
- Environment variables
- Configure a deployed Proa app without rebuilding the binary.
- CI / GitHub Actions
- Gate pull requests with proa check, then deploy from the same pipeline.