From 158628f1a71c810947217f936e5204fe8e32a0de Mon Sep 17 00:00:00 2001 From: lewis Date: Sun, 18 Jan 2026 13:51:27 +0200 Subject: [PATCH] feat: docs tweaks & standalone frontend --- .env.example | 6 +- Cargo.lock | 28 --- Cargo.toml | 2 +- Dockerfile | 18 +- README.md | 4 +- crates/tranquil-oauth/src/client.rs | 8 +- crates/tranquil-pds/src/lib.rs | 37 +-- .../src/oauth/endpoints/metadata.rs | 52 ---- crates/tranquil-pds/tests/common/mod.rs | 2 +- .../tests/oauth_client_metadata.rs | 114 --------- deploy/nginx/nginx-quadlet.conf | 95 +++++++- deploy/quadlets/tranquil-pds-app.container | 1 - .../quadlets/tranquil-pds-frontend.container | 21 ++ deploy/quadlets/tranquil-pds-minio.container | 7 +- deploy/quadlets/tranquil-pds-nginx.container | 2 +- ...mpose.prod.yml => docker-compose.prod.yaml | 58 +++-- docker-compose.yaml | 23 +- docs/install-containers.md | 226 ++++++++++++++---- docs/install-debian.md | 199 +++++++++++++-- frontend/Dockerfile | 9 + frontend/nginx-quadlet.conf | 38 +++ frontend/nginx.conf | 38 +++ frontend/public/homepage.html | 10 +- frontend/public/oauth/client-metadata.json | 15 ++ frontend/src/locales/en.json | 16 +- justfile | 7 +- nginx.frontend.conf | 170 +++++++++++++ nginx.prod.conf | 87 ------- .../{prometheus.yml => prometheus.yaml} | 2 +- scripts/install-debian.sh | 7 +- scripts/test-infra.sh | 10 +- 31 files changed, 844 insertions(+), 468 deletions(-) delete mode 100644 crates/tranquil-pds/tests/oauth_client_metadata.rs create mode 100644 deploy/quadlets/tranquil-pds-frontend.container rename docker-compose.prod.yml => docker-compose.prod.yaml (81%) create mode 100644 frontend/Dockerfile create mode 100644 frontend/nginx-quadlet.conf create mode 100644 frontend/nginx.conf create mode 100644 frontend/public/oauth/client-metadata.json create mode 100644 nginx.frontend.conf delete mode 100644 nginx.prod.conf rename observability/{prometheus.yml => prometheus.yaml} (85%) diff --git a/.env.example b/.env.example index e63edaf..f6db1d5 100644 --- a/.env.example +++ b/.env.example @@ -140,7 +140,7 @@ AWS_SECRET_ACCESS_KEY=minioadmin # ============================================================================= # If configured, moderation reports will be proxied to this service # instead of being stored locally. The service should implement the -# com.atproto.moderation.createReport endpoint (e.g., Bluesky's Ozone). +# com.atproto.moderation.createReport endpoint (eg., Bluesky's Ozone). # Both URL and DID must be set for proxying to be enabled. # REPORT_SERVICE_URL=https://mod.bsky.app # REPORT_SERVICE_DID=did:plc:ar7c4by46qjdydhdevvrndac @@ -148,7 +148,7 @@ AWS_SECRET_ACCESS_KEY=minioadmin # Age Assurance Override # ============================================================================= # Enable this if you have separately assured the ages of your users -# (e.g., through your own age verification process). When enabled, the PDS +# (eg., through your own age verification process). When enabled, the PDS # will return "assured" status for age assurance checks instead of proxying # to the appview. This helps migrated users avoid the age assurance # catch-22 on bsky.app. @@ -158,8 +158,6 @@ AWS_SECRET_ACCESS_KEY=minioadmin # ============================================================================= # Allow HTTP for proxy requests (development only) # ALLOW_HTTP_PROXY=1 -# Custom frontend directory (defaults to ./frontend/dist) -# FRONTEND_DIR=/path/to/frontend/dist # ============================================================================= # SSO / Social Login # ============================================================================= diff --git a/Cargo.lock b/Cargo.lock index cd0cd39..68910d5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2591,12 +2591,6 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "http-range-header" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" - [[package]] name = "httparse" version = "1.10.1" @@ -3477,16 +3471,6 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" -[[package]] -name = "mime_guess" -version = "2.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" -dependencies = [ - "mime", - "unicase", -] - [[package]] name = "minimal-lexical" version = "0.2.1" @@ -5850,19 +5834,13 @@ dependencies = [ "http 1.4.0", "http-body 1.0.1", "http-body-util", - "http-range-header", - "httpdate", "iri-string", - "mime", - "mime_guess", - "percent-encoding", "pin-project-lite", "tokio", "tokio-util", "tower", "tower-layer", "tower-service", - "tracing", ] [[package]] @@ -6241,12 +6219,6 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" -[[package]] -name = "unicase" -version = "2.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" - [[package]] name = "unicode-bidi" version = "0.3.18" diff --git a/Cargo.toml b/Cargo.toml index fcb00ac..76f9429 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -90,7 +90,7 @@ tokio = { version = "1.48", features = ["macros", "rt-multi-thread", "time", "si tokio-tungstenite = { version = "0.28", features = ["native-tls"] } totp-rs = { version = "5", features = ["qr"] } tower = "0.5" -tower-http = { version = "0.6", features = ["fs", "cors"] } +tower-http = { version = "0.6", features = ["cors"] } tower-layer = "0.3" tracing = "0.1" tracing-subscriber = "0.3" diff --git a/Dockerfile b/Dockerfile index 7839a15..649b8ec 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,29 +1,21 @@ -FROM denoland/deno:alpine AS frontend-builder -WORKDIR /frontend -COPY frontend/ ./ -RUN deno task build - FROM rust:1.92-alpine AS builder -RUN apk add ca-certificates openssl openssl-dev openssl-libs-static pkgconfig musl-dev +RUN apk add --no-cache ca-certificates openssl openssl-dev openssl-libs-static pkgconfig musl-dev WORKDIR /app COPY Cargo.toml Cargo.lock ./ -COPY src ./src -COPY tests ./tests -COPY migrations ./migrations +COPY crates ./crates COPY .sqlx ./.sqlx +COPY migrations ./crates/tranquil-pds/migrations RUN --mount=type=cache,target=/usr/local/cargo/registry \ --mount=type=cache,target=/app/target \ - cargo build --release && \ + SQLX_OFFLINE=true cargo build --release -p tranquil-pds && \ cp target/release/tranquil-pds /tmp/tranquil-pds FROM alpine:3.23 RUN apk add --no-cache msmtp ca-certificates && ln -sf /usr/bin/msmtp /usr/sbin/sendmail COPY --from=builder /tmp/tranquil-pds /usr/local/bin/tranquil-pds -COPY --from=builder /app/migrations /app/migrations -COPY --from=frontend-builder /frontend/dist /app/frontend/dist +COPY migrations /app/migrations WORKDIR /app ENV SERVER_HOST=0.0.0.0 ENV SERVER_PORT=3000 -ENV FRONTEND_DIR=/app/frontend/dist EXPOSE 3000 CMD ["tranquil-pds"] diff --git a/README.md b/README.md index fa5e3de..185b75f 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Another excellent PDS is [Cocoon](https://tangled.org/hailey.at/cocoon), written ## What's different about Tranquil PDS -It is a superset of the reference PDS, including: passkeys and 2FA (WebAuthn/FIDO2, TOTP, backup codes, trusted devices), did:web support (PDS-hosted subdomains or bring-your-own), multi-channel communication (email, discord, telegram, signal) for verification and alerts, granular OAuth scopes with a consent UI showing human-readable descriptions, app passwords with granular permissions (read-only, post-only, or custom scopes), account delegation (letting others manage an account with configurable permission levels), automatic backups to s3-compatible object storage (configurable retention and frequency, one-click restore), and a built-in web UI for account management, OAuth consent, repo browsing, and admin. +It is a superset of the reference PDS, including: passkeys and 2FA (WebAuthn/FIDO2, TOTP, backup codes, trusted devices), SSO login and signup, did:web support (PDS-hosted subdomains or bring-your-own), multi-channel communication (email, discord, telegram, signal) for verification and alerts, granular OAuth scopes with a consent UI showing human-readable descriptions, app passwords with granular permissions (read-only, post-only, or custom scopes), account delegation (letting others manage an account with configurable permission levels), automatic backups to s3-compatible object storage (configurable retention and frequency, one-click restore), and a built-in web UI for account management, OAuth consent, repo browsing, and admin. The PDS itself is a single small binary with no node/npm runtime. It does require postgres, valkey, and s3-compatible storage, which makes setup heavier than the reference PDS's sqlite. The tradeoff is that these are battle-tested pieces of infra that we already know how to scale, back up, and monitor. @@ -45,7 +45,7 @@ Edit `.env.prod` with your values. Generate secrets with `openssl rand -base64 4 ```bash cp .env.prod.example .env.prod -podman-compose -f docker-compose.prod.yml up -d +podman-compose -f docker-compose.prod.yaml up -d ``` ### Installation Guides diff --git a/crates/tranquil-oauth/src/client.rs b/crates/tranquil-oauth/src/client.rs index d75d626..7b81778 100644 --- a/crates/tranquil-oauth/src/client.rs +++ b/crates/tranquil-oauth/src/client.rs @@ -83,9 +83,11 @@ impl ClientMetadataCache { .connect_timeout(std::time::Duration::from_secs(10)) .pool_max_idle_per_host(10) .pool_idle_timeout(std::time::Duration::from_secs(90)) - .user_agent( - "Tranquil-PDS/1.0 (ATProto; +https://tangled.org/lewis.moe/bspds-sandbox)", - ) + .user_agent(concat!( + "Tranquil-PDS/", + env!("CARGO_PKG_VERSION"), + " (ATProto; +https://tangled.org/tranquil.farm/tranquil-pds)" + )) .build() .unwrap_or_else(|_| Client::new()), cache_ttl_secs, diff --git a/crates/tranquil-pds/src/lib.rs b/crates/tranquil-pds/src/lib.rs index bb2bba4..21f5a54 100644 --- a/crates/tranquil-pds/src/lib.rs +++ b/crates/tranquil-pds/src/lib.rs @@ -38,7 +38,6 @@ use state::AppState; pub use sync::util::AccountStatus; use tower::ServiceBuilder; use tower_http::cors::{Any, CorsLayer}; -use tower_http::services::{ServeDir, ServeFile}; pub use types::{AccountState, AtIdentifier, AtUri, Did, Handle, Nsid, Rkey}; pub fn app(state: AppState) -> Router { @@ -525,10 +524,6 @@ pub fn app(state: AppState) -> Router { let oauth_router = Router::new() .route("/jwks", get(oauth::endpoints::oauth_jwks)) - .route( - "/client-metadata.json", - get(oauth::endpoints::frontend_client_metadata), - ) .route("/par", post(oauth::endpoints::pushed_authorization_request)) .route("/authorize", get(oauth::endpoints::authorize_get)) .route("/authorize", post(oauth::endpoints::authorize_post)) @@ -612,7 +607,7 @@ pub fn app(state: AppState) -> Router { get(oauth::endpoints::oauth_authorization_server), ); - let router = Router::new() + Router::new() .nest_service("/xrpc", xrpc_service) .nest("/oauth", oauth_router) .nest("/.well-known", well_known_router) @@ -644,33 +639,5 @@ pub fn app(state: AppState) -> Router { "atproto-content-labelers".parse().unwrap(), ]), ) - .with_state(state); - - let frontend_dir = - std::env::var("FRONTEND_DIR").unwrap_or_else(|_| "./frontend/dist".to_string()); - if std::path::Path::new(&frontend_dir) - .join("index.html") - .exists() - { - let index_path = format!("{}/index.html", frontend_dir); - let homepage_path = format!("{}/homepage.html", frontend_dir); - - let homepage_exists = std::path::Path::new(&homepage_path).exists(); - let homepage_file = if homepage_exists { - homepage_path - } else { - index_path.clone() - }; - - let spa_router = Router::new().fallback_service(ServeFile::new(&index_path)); - - let serve_dir = ServeDir::new(&frontend_dir).not_found_service(ServeFile::new(&index_path)); - - return router - .route_service("/", ServeFile::new(&homepage_file)) - .nest("/app", spa_router) - .fallback_service(serve_dir); - } - - router + .with_state(state) } diff --git a/crates/tranquil-pds/src/oauth/endpoints/metadata.rs b/crates/tranquil-pds/src/oauth/endpoints/metadata.rs index 6de629c..52fad83 100644 --- a/crates/tranquil-pds/src/oauth/endpoints/metadata.rs +++ b/crates/tranquil-pds/src/oauth/endpoints/metadata.rs @@ -82,17 +82,6 @@ pub async fn oauth_authorization_server( "transition:generic".to_string(), "transition:chat.bsky".to_string(), "transition:email".to_string(), - "repo:*".to_string(), - "repo:*?action=create".to_string(), - "repo:*?action=read".to_string(), - "repo:*?action=update".to_string(), - "repo:*?action=delete".to_string(), - "blob:*/*".to_string(), - "rpc:*".to_string(), - "account:*".to_string(), - "account:*?action=read".to_string(), - "account:*?action=write".to_string(), - "identity:*".to_string(), ]), response_types_supported: vec!["code".to_string()], response_modes_supported: Some(vec!["query".to_string(), "fragment".to_string()]), @@ -142,44 +131,3 @@ pub async fn oauth_jwks(State(_state): State) -> Json { }; Json(create_jwk_set(vec![server_key])) } - -#[derive(Debug, Serialize, Deserialize)] -pub struct FrontendClientMetadata { - pub client_id: String, - pub client_name: String, - pub client_uri: String, - pub redirect_uris: Vec, - pub grant_types: Vec, - pub response_types: Vec, - pub scope: String, - pub token_endpoint_auth_method: String, - pub application_type: String, - pub dpop_bound_access_tokens: bool, -} - -pub async fn frontend_client_metadata( - State(_state): State, -) -> Json { - let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); - let base_url = format!("https://{}", pds_hostname); - let client_id = format!("{}/oauth/client-metadata.json", base_url); - Json(FrontendClientMetadata { - client_id, - client_name: "PDS Account Manager".to_string(), - client_uri: base_url.clone(), - redirect_uris: vec![ - format!("{}/app/", base_url), - format!("{}/app/migrate", base_url), - ], - grant_types: vec![ - "authorization_code".to_string(), - "refresh_token".to_string(), - ], - response_types: vec!["code".to_string()], - scope: "atproto transition:generic repo:* blob:*/* rpc:* rpc:com.atproto.server.createAccount?aud=* account:*?action=manage identity:*" - .to_string(), - token_endpoint_auth_method: "none".to_string(), - application_type: "web".to_string(), - dpop_bound_access_tokens: true, - }) -} diff --git a/crates/tranquil-pds/tests/common/mod.rs b/crates/tranquil-pds/tests/common/mod.rs index 2283d41..7f94a27 100644 --- a/crates/tranquil-pds/tests/common/mod.rs +++ b/crates/tranquil-pds/tests/common/mod.rs @@ -162,7 +162,7 @@ async fn setup_with_external_infra() -> String { #[cfg(not(feature = "external-infra"))] async fn setup_with_testcontainers() -> String { - let s3_container = GenericImage::new("minio/minio", "latest") + let s3_container = GenericImage::new("cgr.dev/chainguard/minio", "latest") .with_exposed_port(ContainerPort::Tcp(9000)) .with_env_var("MINIO_ROOT_USER", "minioadmin") .with_env_var("MINIO_ROOT_PASSWORD", "minioadmin") diff --git a/crates/tranquil-pds/tests/oauth_client_metadata.rs b/crates/tranquil-pds/tests/oauth_client_metadata.rs deleted file mode 100644 index 06938c3..0000000 --- a/crates/tranquil-pds/tests/oauth_client_metadata.rs +++ /dev/null @@ -1,114 +0,0 @@ -mod common; -use common::*; -use reqwest::StatusCode; -use serde_json::Value; - -#[tokio::test] -async fn test_frontend_client_metadata_returns_valid_json() { - let client = client(); - let res = client - .get(format!("{}/oauth/client-metadata.json", base_url().await)) - .send() - .await - .expect("Failed to send request"); - assert_eq!(res.status(), StatusCode::OK); - let body: Value = res.json().await.expect("Should return valid JSON"); - assert!( - body["client_id"].as_str().is_some(), - "Should have client_id" - ); - assert!( - body["client_name"].as_str().is_some(), - "Should have client_name" - ); - assert!( - body["redirect_uris"].as_array().is_some(), - "Should have redirect_uris" - ); - assert!( - body["grant_types"].as_array().is_some(), - "Should have grant_types" - ); - assert!( - body["response_types"].as_array().is_some(), - "Should have response_types" - ); - assert!(body["scope"].as_str().is_some(), "Should have scope"); - assert!( - body["token_endpoint_auth_method"].as_str().is_some(), - "Should have token_endpoint_auth_method" - ); -} - -#[tokio::test] -async fn test_frontend_client_metadata_correct_values() { - let client = client(); - let res = client - .get(format!("{}/oauth/client-metadata.json", base_url().await)) - .send() - .await - .expect("Failed to send request"); - assert_eq!(res.status(), StatusCode::OK); - let body: Value = res.json().await.unwrap(); - let client_id = body["client_id"].as_str().unwrap(); - assert!( - client_id.ends_with("/oauth/client-metadata.json"), - "client_id should end with /oauth/client-metadata.json" - ); - let grant_types = body["grant_types"].as_array().unwrap(); - let grant_strs: Vec<&str> = grant_types.iter().filter_map(|v| v.as_str()).collect(); - assert!( - grant_strs.contains(&"authorization_code"), - "Should support authorization_code grant" - ); - assert!( - grant_strs.contains(&"refresh_token"), - "Should support refresh_token grant" - ); - let response_types = body["response_types"].as_array().unwrap(); - let response_strs: Vec<&str> = response_types.iter().filter_map(|v| v.as_str()).collect(); - assert!( - response_strs.contains(&"code"), - "Should support code response type" - ); - assert_eq!( - body["token_endpoint_auth_method"].as_str(), - Some("none"), - "Should be public client (none auth)" - ); - assert_eq!( - body["application_type"].as_str(), - Some("web"), - "Should be web application" - ); - assert_eq!( - body["dpop_bound_access_tokens"].as_bool(), - Some(true), - "AT Protocol requires DPoP-bound access tokens" - ); - let scope = body["scope"].as_str().unwrap(); - assert!(scope.contains("atproto"), "Scope should include atproto"); -} - -#[tokio::test] -async fn test_frontend_client_metadata_redirect_uri_matches_client_uri() { - let client = client(); - let res = client - .get(format!("{}/oauth/client-metadata.json", base_url().await)) - .send() - .await - .expect("Failed to send request"); - assert_eq!(res.status(), StatusCode::OK); - let body: Value = res.json().await.unwrap(); - let client_uri = body["client_uri"].as_str().unwrap(); - let redirect_uris = body["redirect_uris"].as_array().unwrap(); - assert!( - !redirect_uris.is_empty(), - "Should have at least one redirect URI" - ); - let redirect_uri = redirect_uris[0].as_str().unwrap(); - assert!( - redirect_uri.starts_with(client_uri), - "Redirect URI should be on same origin as client_uri" - ); -} diff --git a/deploy/nginx/nginx-quadlet.conf b/deploy/nginx/nginx-quadlet.conf index 03db9b3..5b65cb7 100644 --- a/deploy/nginx/nginx-quadlet.conf +++ b/deploy/nginx/nginx-quadlet.conf @@ -1,40 +1,53 @@ worker_processes auto; error_log /var/log/nginx/error.log warn; + events { worker_connections 4096; } + http { include /etc/nginx/mime.types; default_type application/octet-stream; access_log /var/log/nginx/access.log; + sendfile on; keepalive_timeout 65; + gzip on; gzip_types text/plain text/css application/json application/javascript text/xml application/xml; + ssl_protocols TLSv1.2 TLSv1.3; ssl_prefer_server_ciphers off; ssl_session_cache shared:SSL:10m; ssl_stapling on; ssl_stapling_verify on; + server { listen 80; listen [::]:80; server_name _; + location /.well-known/acme-challenge/ { root /var/www/acme; } + location / { return 301 https://$host$request_uri; } } + server { - listen 443 ssl http2; - listen [::]:443 ssl http2; + listen 443 ssl; + listen [::]:443 ssl; + http2 on; server_name _; + ssl_certificate /etc/nginx/certs/fullchain.pem; ssl_certificate_key /etc/nginx/certs/privkey.pem; + client_max_body_size 10G; - location / { + + location /xrpc/ { proxy_pass http://127.0.0.1:3000; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; @@ -46,6 +59,82 @@ http { proxy_read_timeout 86400; proxy_send_timeout 86400; proxy_buffering off; + proxy_request_buffering off; + } + + location = /oauth/client-metadata.json { + proxy_pass http://127.0.0.1:8080; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header Accept-Encoding ""; + sub_filter_once off; + sub_filter_types application/json; + sub_filter '__PDS_HOSTNAME__' $host; + } + + location /oauth/ { + proxy_pass http://127.0.0.1:3000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 300; + proxy_send_timeout 300; + } + + location /.well-known/ { + proxy_pass http://127.0.0.1:3000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location = /metrics { + proxy_pass http://127.0.0.1:3000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location = /health { + proxy_pass http://127.0.0.1:3000; + proxy_http_version 1.1; + proxy_set_header Host $host; + } + + location = /robots.txt { + proxy_pass http://127.0.0.1:3000; + proxy_http_version 1.1; + proxy_set_header Host $host; + } + + location = /logo { + proxy_pass http://127.0.0.1:3000; + proxy_http_version 1.1; + proxy_set_header Host $host; + } + + location ~ ^/u/[^/]+/did\.json$ { + proxy_pass http://127.0.0.1:3000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location / { + proxy_pass http://127.0.0.1:8080; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; } } } diff --git a/deploy/quadlets/tranquil-pds-app.container b/deploy/quadlets/tranquil-pds-app.container index 47beb8f..7494a4e 100644 --- a/deploy/quadlets/tranquil-pds-app.container +++ b/deploy/quadlets/tranquil-pds-app.container @@ -12,7 +12,6 @@ Environment=S3_ENDPOINT=http://localhost:9000 Environment=AWS_REGION=us-east-1 Environment=S3_BUCKET=pds-blobs Environment=VALKEY_URL=redis://localhost:6379 -Environment=FRONTEND_DIR=/app/frontend/dist HealthCmd=wget -q --spider http://localhost:3000/xrpc/_health HealthInterval=30s HealthTimeout=10s diff --git a/deploy/quadlets/tranquil-pds-frontend.container b/deploy/quadlets/tranquil-pds-frontend.container new file mode 100644 index 0000000..f811ad9 --- /dev/null +++ b/deploy/quadlets/tranquil-pds-frontend.container @@ -0,0 +1,21 @@ +[Unit] +Description=Tranquil PDS frontend +After=tranquil-pds-app.service + +[Container] +ContainerName=tranquil-pds-frontend +Image=localhost/tranquil-pds-frontend:latest +Pod=tranquil-pds.pod +Volume=/opt/tranquil-pds/frontend/nginx-quadlet.conf:/etc/nginx/conf.d/default.conf:ro,Z +HealthCmd=wget -q --spider http://localhost:8080/ +HealthInterval=30s +HealthTimeout=10s +HealthRetries=3 +HealthStartPeriod=5s + +[Service] +Restart=always +RestartSec=10 + +[Install] +WantedBy=default.target diff --git a/deploy/quadlets/tranquil-pds-minio.container b/deploy/quadlets/tranquil-pds-minio.container index f161052..13e91fd 100644 --- a/deploy/quadlets/tranquil-pds-minio.container +++ b/deploy/quadlets/tranquil-pds-minio.container @@ -2,17 +2,12 @@ Description=Tranquil PDS minio object storage [Container] ContainerName=tranquil-pds-minio -Image=docker.io/minio/minio:RELEASE.2025-10-15T17-29-55Z +Image=cgr.dev/chainguard/minio:latest Pod=tranquil-pds.pod Environment=MINIO_ROOT_USER=minioadmin Secret=tranquil-pds-minio-password,type=env,target=MINIO_ROOT_PASSWORD Volume=/srv/tranquil-pds/minio:/data:Z Exec=server /data --console-address :9001 -HealthCmd=curl -f http://localhost:9000/minio/health/live || exit 1 -HealthInterval=30s -HealthTimeout=10s -HealthRetries=3 -HealthStartPeriod=10s [Service] Restart=always RestartSec=10 diff --git a/deploy/quadlets/tranquil-pds-nginx.container b/deploy/quadlets/tranquil-pds-nginx.container index a2a4a49..766d69d 100644 --- a/deploy/quadlets/tranquil-pds-nginx.container +++ b/deploy/quadlets/tranquil-pds-nginx.container @@ -1,6 +1,6 @@ [Unit] Description=Tranquil PDS nginx reverse proxy -After=tranquil-pds-app.service +After=tranquil-pds-app.service tranquil-pds-frontend.service [Container] ContainerName=tranquil-pds-nginx Image=docker.io/library/nginx:1.28-alpine diff --git a/docker-compose.prod.yml b/docker-compose.prod.yaml similarity index 81% rename from docker-compose.prod.yml rename to docker-compose.prod.yaml index eef7611..f58c8ac 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yaml @@ -5,8 +5,6 @@ services: dockerfile: Dockerfile image: tranquil-pds:latest restart: unless-stopped - ports: - - "127.0.0.1:3000:3000" environment: SERVER_HOST: "0.0.0.0" SERVER_PORT: "3000" @@ -22,7 +20,6 @@ services: DPOP_SECRET: "${DPOP_SECRET:?DPOP_SECRET is required (min 32 chars)}" MASTER_KEY: "${MASTER_KEY:?MASTER_KEY is required (min 32 chars)}" CRAWLERS: "${CRAWLERS:-https://bsky.network}" - FRONTEND_DIR: "/app/frontend/dist" depends_on: db: condition: service_healthy @@ -42,6 +39,26 @@ services: memory: 1G reservations: memory: 256M + + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + image: tranquil-pds-frontend:latest + restart: unless-stopped + healthcheck: + test: ["CMD", "wget", "-q", "--spider", "http://localhost:80/"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 5s + deploy: + resources: + limits: + memory: 128M + reservations: + memory: 32M + db: image: postgres:18-alpine restart: unless-stopped @@ -63,8 +80,9 @@ services: memory: 512M reservations: memory: 128M + minio: - image: minio/minio:RELEASE.2025-10-15T17-29-55Z + image: cgr.dev/chainguard/minio:latest restart: unless-stopped command: server /data --console-address ":9001" environment: @@ -72,33 +90,32 @@ services: MINIO_ROOT_PASSWORD: "${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD is required}" volumes: - minio_data:/data - healthcheck: - test: ["CMD", "mc", "ready", "local"] - interval: 30s - timeout: 10s - retries: 3 - start_period: 10s deploy: resources: limits: memory: 512M reservations: memory: 128M + minio-init: - image: minio/mc:RELEASE.2025-07-16T15-35-03Z + image: cgr.dev/chainguard/minio-client:latest-dev depends_on: - minio: - condition: service_healthy + - minio entrypoint: > /bin/sh -c " - mc alias set local http://minio:9000 $${MINIO_ROOT_USER} $${MINIO_ROOT_PASSWORD}; + for i in 1 2 3 4 5 6 7 8 9 10; do + mc alias set local http://minio:9000 $${MINIO_ROOT_USER} $${MINIO_ROOT_PASSWORD} && break; + echo 'Waiting for minio...'; sleep 2; + done; mc mb --ignore-existing local/pds-blobs; + mc mb --ignore-existing local/pds-backups; mc anonymous set none local/pds-blobs; exit 0; " environment: MINIO_ROOT_USER: "${MINIO_ROOT_USER:-minioadmin}" MINIO_ROOT_PASSWORD: "${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD is required}" + valkey: image: valkey/valkey:9-alpine restart: unless-stopped @@ -117,45 +134,50 @@ services: memory: 300M reservations: memory: 64M + nginx: - image: nginx:1.28-alpine + image: nginx:1.29-alpine restart: unless-stopped ports: - "80:80" - "443:443" volumes: - - ./nginx.prod.conf:/etc/nginx/nginx.conf:ro + - ./nginx.frontend.conf:/etc/nginx/nginx.conf:ro - ./certs:/etc/nginx/certs:ro - acme_challenge:/var/www/acme:ro depends_on: - tranquil-pds + - frontend healthcheck: test: ["CMD", "nginx", "-t"] interval: 30s timeout: 10s retries: 3 + certbot: image: certbot/certbot:v5.2.2 volumes: - ./certs:/etc/letsencrypt - acme_challenge:/var/www/acme entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew --webroot -w /var/www/acme; sleep 12h & wait $${!}; done'" + prometheus: image: prom/prometheus:v3.8.0 restart: unless-stopped ports: - "127.0.0.1:9090:9090" volumes: - - ./observability/prometheus.yml:/etc/prometheus/prometheus.yml:ro + - ./observability/prometheus.yaml:/etc/prometheus/prometheus.yaml:ro - prometheus_data:/prometheus command: - - '--config.file=/etc/prometheus/prometheus.yml' + - '--config.file=/etc/prometheus/prometheus.yaml' - '--storage.tsdb.path=/prometheus' - '--storage.tsdb.retention.time=30d' deploy: resources: limits: memory: 256M + volumes: postgres_data: minio_data: diff --git a/docker-compose.yaml b/docker-compose.yaml index 9510f69..a8e8f21 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -16,6 +16,17 @@ services: - db - objsto - cache + + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + image: tranquil-pds-frontend + ports: + - "8080:80" + depends_on: + - app + db: image: postgres:18-alpine environment: @@ -26,8 +37,9 @@ services: - "5432:5432" volumes: - postgres_data:/var/lib/postgresql + objsto: - image: minio/minio + image: cgr.dev/chainguard/minio:latest ports: - "9000:9000" - "9001:9001" @@ -37,24 +49,27 @@ services: volumes: - minio_data:/data command: server /data --console-address ":9001" + cache: - image: valkey/valkey:8-alpine + image: valkey/valkey:9-alpine ports: - "6379:6379" volumes: - valkey_data:/data + prometheus: image: prom/prometheus:v3.8.0 ports: - "9090:9090" volumes: - - ./observability/prometheus.yml:/etc/prometheus/prometheus.yml:ro + - ./observability/prometheus.yaml:/etc/prometheus/prometheus.yaml:ro - prometheus_data:/prometheus command: - - '--config.file=/etc/prometheus/prometheus.yml' + - '--config.file=/etc/prometheus/prometheus.yaml' - '--storage.tsdb.path=/prometheus' depends_on: - app + volumes: postgres_data: minio_data: diff --git a/docs/install-containers.md b/docs/install-containers.md index 3b5c07e..3278130 100644 --- a/docs/install-containers.md +++ b/docs/install-containers.md @@ -1,15 +1,21 @@ # Tranquil PDS Containerized Production Deployment -> **Warning**: These instructions are untested and theoretical, written from the top of Lewis' head. They may contain errors or omissions. This warning will be removed once the guide has been verified. + This guide covers deploying Tranquil PDS using containers with podman. + - **Debian 13+**: Uses systemd quadlets (modern, declarative container management) - **Alpine 3.23+**: Uses OpenRC service script with podman-compose + ## Prerequisites + - A VPS with at least 2GB RAM and 20GB disk - A domain name pointing to your server's IP - A **wildcard TLS certificate** for `*.pds.example.com` (user handles are served as subdomains) - Root or sudo access + ## Quick Start (Docker/Podman Compose) + If you just want to get running quickly: + ```sh cp .env.example .env ``` @@ -18,64 +24,139 @@ Edit `.env` with your values. Generate secrets with `openssl rand -base64 48`. Build and start: ```sh -podman-compose -f docker-compose.prod.yml up -d +podman build -t tranquil-pds:latest . +podman build -t tranquil-pds-frontend:latest ./frontend +podman-compose -f docker-compose.prod.yaml up -d ``` Get initial certificate (after DNS is configured): ```sh -podman-compose -f docker-compose.prod.yml run --rm certbot certonly \ - --webroot -w /var/www/acme -d pds.example.com -podman-compose -f docker-compose.prod.yml restart nginx +podman-compose -f docker-compose.prod.yaml run --rm certbot certonly \ + --webroot -w /var/www/acme -d pds.example.com -d '*.pds.example.com' +ln -sf live/pds.example.com/fullchain.pem certs/fullchain.pem +ln -sf live/pds.example.com/privkey.pem certs/privkey.pem +podman-compose -f docker-compose.prod.yaml restart nginx ``` + For production setups with proper service management, continue to either the Debian or Alpine section below. + +## Standalone Containers (No Compose) + +If you already have postgres, valkey, and minio running on the host (eg., from the [Debian install guide](install-debian.md)), you can run just the app containers. + +Build the images: +```sh +podman build -t tranquil-pds:latest . +podman build -t tranquil-pds-frontend:latest ./frontend +``` + +Run the backend with host networking (so it can access postgres/valkey/minio on localhost): +```sh +podman run -d --name tranquil-pds \ + --network=host \ + --env-file /etc/tranquil-pds/tranquil-pds.env \ + tranquil-pds:latest +``` + +Run the frontend with port mapping (the container's nginx listens on port 80): +```sh +podman run -d --name tranquil-pds-frontend \ + -p 8080:80 \ + tranquil-pds-frontend:latest +``` + +Then configure your host nginx to proxy to both containers. Replace the static file `try_files` directives with proxy passes: + +```nginx +# API routes to backend +location /xrpc/ { + proxy_pass http://127.0.0.1:3000; + # ... (see Debian guide for full proxy headers) +} + +# Static routes to frontend container +location / { + proxy_pass http://127.0.0.1:8080; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; +} +``` + +See the [Debian install guide](install-debian.md) for the full nginx config with all API routes. + --- + # Debian 13+ with Systemd Quadlets + Quadlets are the modern way to run podman containers under systemd. -## 1. Install Podman + +## Install Podman + ```bash apt update apt install -y podman ``` -## 2. Create Directory Structure + +## Create Directory Structure + ```bash mkdir -p /etc/containers/systemd mkdir -p /srv/tranquil-pds/{postgres,minio,valkey,certs,acme,config} ``` -## 3. Create Environment File + +## Create Environment File + ```bash cp /opt/tranquil-pds/.env.example /srv/tranquil-pds/config/tranquil-pds.env chmod 600 /srv/tranquil-pds/config/tranquil-pds.env ``` + Edit `/srv/tranquil-pds/config/tranquil-pds.env` and fill in your values. Generate secrets with: ```bash openssl rand -base64 48 ``` + For quadlets, also add `DATABASE_URL` with the full connection string (systemd doesn't support variable expansion). -## 4. Install Quadlet Definitions + +## Install Quadlet Definitions + Copy the quadlet files from the repository: ```bash cp /opt/tranquil-pds/deploy/quadlets/*.pod /etc/containers/systemd/ cp /opt/tranquil-pds/deploy/quadlets/*.container /etc/containers/systemd/ ``` + Note: Systemd doesn't support shell-style variable expansion in `Environment=` lines. The quadlet files expect DATABASE_URL to be set in the environment file. -## 5. Create nginx Configuration + +## Create nginx Configuration + ```bash -cp /opt/tranquil-pds/deploy/nginx/nginx-quadlet.conf /srv/tranquil-pds/config/nginx.conf +cp /opt/tranquil-pds/nginx.frontend.conf /srv/tranquil-pds/config/nginx.conf ``` -## 6. Build Tranquil PDS Image + +## Clone and Build Images + ```bash cd /opt -git clone https://tangled.org/lewis.moe/bspds-sandbox tranquil-pds +git clone https://tangled.org/tranquil.farm/tranquil-pds tranquil-pds cd tranquil-pds podman build -t tranquil-pds:latest . +podman build -t tranquil-pds-frontend:latest ./frontend ``` -## 7. Create Podman Secrets + +## Create Podman Secrets + ```bash source /srv/tranquil-pds/config/tranquil-pds.env echo "$DB_PASSWORD" | podman secret create tranquil-pds-db-password - echo "$MINIO_ROOT_PASSWORD" | podman secret create tranquil-pds-minio-password - ``` -## 8. Start Services and Initialize + +## Start Services and Initialize + ```bash systemctl daemon-reload systemctl start tranquil-pds-db tranquil-pds-minio tranquil-pds-valkey @@ -87,7 +168,7 @@ Create the minio buckets: podman run --rm --pod tranquil-pds \ -e MINIO_ROOT_USER=minioadmin \ -e MINIO_ROOT_PASSWORD=your-minio-password \ - docker.io/minio/mc:RELEASE.2025-07-16T15-35-03Z \ + cgr.dev/chainguard/minio-client:latest-dev \ sh -c "mc alias set local http://localhost:9000 \$MINIO_ROOT_USER \$MINIO_ROOT_PASSWORD && mc mb --ignore-existing local/pds-blobs && mc mb --ignore-existing local/pds-backups" ``` @@ -96,8 +177,10 @@ Run migrations: cargo install sqlx-cli --no-default-features --features postgres DATABASE_URL="postgres://tranquil_pds:your-db-password@localhost:5432/pds" sqlx migrate run --source /opt/tranquil-pds/migrations ``` -## 9. Obtain Wildcard SSL Certificate -User handles are served as subdomains (e.g., `alice.pds.example.com`), so you need a wildcard certificate. Wildcard certs require DNS-01 validation. + +## Obtain Wildcard SSL Certificate + +User handles are served as subdomains (eg., `alice.pds.example.com`), so you need a wildcard certificate. Wildcard certs require DNS-01 validation. Create temporary self-signed cert to start services: ```bash @@ -105,7 +188,7 @@ openssl req -x509 -nodes -days 1 -newkey rsa:2048 \ -keyout /srv/tranquil-pds/certs/privkey.pem \ -out /srv/tranquil-pds/certs/fullchain.pem \ -subj "/CN=pds.example.com" -systemctl start tranquil-pds-app tranquil-pds-nginx +systemctl start tranquil-pds-app tranquil-pds-frontend tranquil-pds-nginx ``` Get a wildcard certificate using DNS validation: @@ -117,9 +200,10 @@ podman run --rm -it \ -d pds.example.com -d '*.pds.example.com' \ --agree-tos --email you@example.com ``` + Follow the prompts to add TXT records to your DNS. Note: manual mode doesn't auto-renew. -For automated renewal, use a DNS provider plugin (e.g., cloudflare, route53). +For automated renewal, use a DNS provider plugin (eg., cloudflare, route53). Link certificates and restart: ```bash @@ -127,11 +211,15 @@ ln -sf /srv/tranquil-pds/certs/live/pds.example.com/fullchain.pem /srv/tranquil- ln -sf /srv/tranquil-pds/certs/live/pds.example.com/privkey.pem /srv/tranquil-pds/certs/privkey.pem systemctl restart tranquil-pds-nginx ``` -## 10. Enable All Services + +## Enable All Services + ```bash -systemctl enable tranquil-pds-db tranquil-pds-minio tranquil-pds-valkey tranquil-pds-app tranquil-pds-nginx +systemctl enable tranquil-pds-db tranquil-pds-minio tranquil-pds-valkey tranquil-pds-app tranquil-pds-frontend tranquil-pds-nginx ``` -## 11. Configure Firewall + +## Configure Firewall + ```bash apt install -y ufw ufw allow ssh @@ -139,59 +227,81 @@ ufw allow 80/tcp ufw allow 443/tcp ufw enable ``` -## 12. Certificate Renewal + +## Certificate Renewal + Add to root's crontab (`crontab -e`): ``` 0 0 * * * podman run --rm -v /srv/tranquil-pds/certs:/etc/letsencrypt:Z -v /srv/tranquil-pds/acme:/var/www/acme:Z docker.io/certbot/certbot:v5.2.2 renew --quiet && systemctl reload tranquil-pds-nginx ``` + --- + # Alpine 3.23+ with OpenRC + Alpine uses OpenRC, not systemd. We'll use podman-compose with an OpenRC service wrapper. -## 1. Install Podman + +## Install Podman + ```sh apk update apk add podman podman-compose fuse-overlayfs cni-plugins rc-update add cgroups rc-service cgroups start ``` + Enable podman socket for compose: ```sh rc-update add podman rc-service podman start ``` -## 2. Create Directory Structure + +## Create Directory Structure + ```sh mkdir -p /srv/tranquil-pds/{data,config} mkdir -p /srv/tranquil-pds/data/{postgres,minio,valkey,certs,acme} ``` -## 3. Clone Repository and Build + +## Clone Repository and Build Images + ```sh cd /opt -git clone https://tangled.org/lewis.moe/bspds-sandbox tranquil-pds +git clone https://tangled.org/tranquil.farm/tranquil-pds tranquil-pds cd tranquil-pds podman build -t tranquil-pds:latest . +podman build -t tranquil-pds-frontend:latest ./frontend ``` -## 4. Create Environment File + +## Create Environment File + ```sh cp /opt/tranquil-pds/.env.example /srv/tranquil-pds/config/tranquil-pds.env chmod 600 /srv/tranquil-pds/config/tranquil-pds.env ``` + Edit `/srv/tranquil-pds/config/tranquil-pds.env` and fill in your values. Generate secrets with: ```sh openssl rand -base64 48 ``` -## 5. Set Up Compose and nginx + +## Set Up Compose and nginx + Copy the production compose and nginx configs: ```sh -cp /opt/tranquil-pds/docker-compose.prod.yml /srv/tranquil-pds/docker-compose.yml -cp /opt/tranquil-pds/nginx.prod.conf /srv/tranquil-pds/config/nginx.conf +cp /opt/tranquil-pds/docker-compose.prod.yaml /srv/tranquil-pds/docker-compose.yml +cp /opt/tranquil-pds/nginx.frontend.conf /srv/tranquil-pds/config/nginx.conf ``` + Edit `/srv/tranquil-pds/docker-compose.yml` to adjust paths if needed: - Update volume mounts to use `/srv/tranquil-pds/data/` paths -- Update nginx cert paths to match `/srv/tranquil-pds/data/certs/` +- Update nginx config path to `/srv/tranquil-pds/config/nginx.conf` + Edit `/srv/tranquil-pds/config/nginx.conf` to update cert paths: - Change `/etc/nginx/certs/live/${PDS_HOSTNAME}/` to `/etc/nginx/certs/` -## 6. Create OpenRC Service + +## Create OpenRC Service + ```sh cat > /etc/init.d/tranquil-pds << 'EOF' #!/sbin/openrc-run @@ -223,7 +333,9 @@ stop() { EOF chmod +x /etc/init.d/tranquil-pds ``` -## 7. Initialize Services + +## Initialize Services + Start services: ```sh rc-service tranquil-pds start @@ -236,7 +348,7 @@ source /srv/tranquil-pds/config/tranquil-pds.env podman run --rm --network tranquil-pds_default \ -e MINIO_ROOT_USER="$MINIO_ROOT_USER" \ -e MINIO_ROOT_PASSWORD="$MINIO_ROOT_PASSWORD" \ - docker.io/minio/mc:RELEASE.2025-07-16T15-35-03Z \ + cgr.dev/chainguard/minio-client:latest-dev \ sh -c 'mc alias set local http://minio:9000 $MINIO_ROOT_USER $MINIO_ROOT_PASSWORD && mc mb --ignore-existing local/pds-blobs && mc mb --ignore-existing local/pds-backups' ``` @@ -249,8 +361,10 @@ cargo install sqlx-cli --no-default-features --features postgres DB_IP=$(podman inspect tranquil-pds-db-1 --format '{{.NetworkSettings.Networks.tranquil-pds_default.IPAddress}}') DATABASE_URL="postgres://tranquil_pds:$DB_PASSWORD@$DB_IP:5432/pds" sqlx migrate run --source /opt/tranquil-pds/migrations ``` -## 8. Obtain Wildcard SSL Certificate -User handles are served as subdomains (e.g., `alice.pds.example.com`), so you need a wildcard certificate. Wildcard certs require DNS-01 validation. + +## Obtain Wildcard SSL Certificate + +User handles are served as subdomains (eg., `alice.pds.example.com`), so you need a wildcard certificate. Wildcard certs require DNS-01 validation. Create temporary self-signed cert to start services: ```sh @@ -270,6 +384,7 @@ podman run --rm -it \ -d pds.example.com -d '*.pds.example.com' \ --agree-tos --email you@example.com ``` + Follow the prompts to add TXT records to your DNS. Note: manual mode doesn't auto-renew. Link certificates and restart: @@ -278,11 +393,15 @@ ln -sf /srv/tranquil-pds/data/certs/live/pds.example.com/fullchain.pem /srv/tran ln -sf /srv/tranquil-pds/data/certs/live/pds.example.com/privkey.pem /srv/tranquil-pds/data/certs/privkey.pem rc-service tranquil-pds restart ``` -## 9. Enable Service at Boot + +## Enable Service at Boot + ```sh rc-update add tranquil-pds ``` -## 10. Configure Firewall + +## Configure Firewall + ```sh apk add iptables ip6tables iptables -A INPUT -p tcp --dport 22 -j ACCEPT @@ -302,50 +421,67 @@ rc-update add ip6tables /etc/init.d/iptables save /etc/init.d/ip6tables save ``` -## 11. Certificate Renewal + +## Certificate Renewal + Add to root's crontab (`crontab -e`): ``` 0 0 * * * podman run --rm -v /srv/tranquil-pds/data/certs:/etc/letsencrypt -v /srv/tranquil-pds/data/acme:/var/www/acme docker.io/certbot/certbot:v5.2.2 renew --quiet && rc-service tranquil-pds restart ``` + --- + # Verification and Maintenance + ## Verify Installation + ```sh curl -s https://pds.example.com/xrpc/_health | jq curl -s https://pds.example.com/.well-known/atproto-did ``` + ## View Logs + **Debian:** ```bash journalctl -u tranquil-pds-app -f podman logs -f tranquil-pds-app +podman logs -f tranquil-pds-frontend ``` + **Alpine:** ```sh podman-compose -f /srv/tranquil-pds/docker-compose.yml logs -f podman logs -f tranquil-pds-tranquil-pds-1 +podman logs -f tranquil-pds-frontend-1 ``` + ## Update Tranquil PDS + ```sh cd /opt/tranquil-pds git pull podman build -t tranquil-pds:latest . +podman build -t tranquil-pds-frontend:latest ./frontend ``` Debian: ```bash -systemctl restart tranquil-pds-app +systemctl restart tranquil-pds-app tranquil-pds-frontend ``` Alpine: ```sh rc-service tranquil-pds restart ``` + ## Backup Database + **Debian:** ```bash podman exec tranquil-pds-db pg_dump -U tranquil_pds pds > /var/backups/pds-$(date +%Y%m%d).sql ``` + **Alpine:** ```sh podman exec tranquil-pds-db-1 pg_dump -U tranquil_pds pds > /var/backups/pds-$(date +%Y%m%d).sql @@ -353,8 +489,12 @@ podman exec tranquil-pds-db-1 pg_dump -U tranquil_pds pds > /var/backups/pds-$(d ## Custom Homepage -Mount a `homepage.html` into the container's frontend directory and it becomes your landing page. Go nuts with it. Account dashboard is at `/app/` so you won't break anything. +The frontend container serves `homepage.html` as the landing page. To customize it, either: +1. Build a custom frontend image with your own `homepage.html` +2. Mount a custom `homepage.html` into the frontend container + +Example custom homepage: ```html diff --git a/docs/install-debian.md b/docs/install-debian.md index 4b09265..3f33654 100644 --- a/docs/install-debian.md +++ b/docs/install-debian.md @@ -1,24 +1,33 @@ # Tranquil PDS Production Installation on Debian + This guide covers installing Tranquil PDS on Debian 13. ## Prerequisites + - A VPS with at least 2GB RAM and 20GB disk - A domain name pointing to your server's IP - A wildcard TLS certificate for `*.pds.example.com` (user handles are served as subdomains) - Root or sudo access -## 1. System Setup + +## System Setup + ```bash apt update && apt upgrade -y apt install -y curl git build-essential pkg-config libssl-dev ``` -## 2. Install Rust + +## Install Rust + ```bash curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y source ~/.cargo/env rustup default stable ``` + This installs the latest stable Rust. -## 3. Install postgres + +## Install postgres + ```bash apt install -y postgresql postgresql-contrib systemctl enable postgresql @@ -27,7 +36,9 @@ sudo -u postgres psql -c "CREATE USER tranquil_pds WITH PASSWORD 'your-secure-pa sudo -u postgres psql -c "CREATE DATABASE pds OWNER tranquil_pds;" sudo -u postgres psql -c "GRANT ALL PRIVILEGES ON DATABASE pds TO tranquil_pds;" ``` -## 4. Install minio + +## Install minio + ```bash curl -O https://dl.min.io/server/minio/release/linux-amd64/minio chmod +x minio @@ -59,6 +70,7 @@ systemctl daemon-reload systemctl enable minio systemctl start minio ``` + Create the buckets (wait a few seconds for minio to start): ```bash curl -O https://dl.min.io/client/mc/release/linux-amd64/mc @@ -68,51 +80,70 @@ mc alias set local http://localhost:9000 minioadmin your-minio-password mc mb local/pds-blobs mc mb local/pds-backups ``` -## 5. Install valkey + +## Install valkey + ```bash apt install -y valkey systemctl enable valkey-server systemctl start valkey-server ``` -## 6. Install deno (for frontend build) + +## Install deno (for frontend build) + ```bash curl -fsSL https://deno.land/install.sh | sh export PATH="$HOME/.deno/bin:$PATH" echo 'export PATH="$HOME/.deno/bin:$PATH"' >> ~/.bashrc ``` -## 7. Clone and Build Tranquil PDS + +## Clone and Build Tranquil PDS + ```bash cd /opt -git clone https://tangled.org/lewis.moe/bspds-sandbox tranquil-pds +git clone https://tangled.org/tranquil.farm/tranquil-pds tranquil-pds cd tranquil-pds cd frontend deno task build cd .. cargo build --release ``` -## 8. Install sqlx-cli and Run Migrations + +## Install sqlx-cli and Run Migrations + ```bash cargo install sqlx-cli --no-default-features --features postgres export DATABASE_URL="postgres://tranquil_pds:your-secure-password@localhost:5432/pds" sqlx migrate run ``` -## 9. Configure Tranquil PDS + +## Configure Tranquil PDS + ```bash mkdir -p /etc/tranquil-pds cp /opt/tranquil-pds/.env.example /etc/tranquil-pds/tranquil-pds.env chmod 600 /etc/tranquil-pds/tranquil-pds.env ``` + Edit `/etc/tranquil-pds/tranquil-pds.env` and fill in your values. Generate secrets with: ```bash openssl rand -base64 48 ``` -## 10. Create Systemd Service + +## Install Frontend Files + +```bash +mkdir -p /var/www/tranquil-pds +cp -r /opt/tranquil-pds/frontend/dist/* /var/www/tranquil-pds/ +chown -R www-data:www-data /var/www/tranquil-pds +``` + +## Create Systemd Service + ```bash useradd -r -s /sbin/nologin tranquil-pds cp /opt/tranquil-pds/target/release/tranquil-pds /usr/local/bin/ -mkdir -p /var/lib/tranquil-pds -cp -r /opt/tranquil-pds/frontend/dist /var/lib/tranquil-pds/frontend -chown -R tranquil-pds:tranquil-pds /var/lib/tranquil-pds + cat > /etc/systemd/system/tranquil-pds.service << 'EOF' [Unit] Description=Tranquil PDS - AT Protocol PDS @@ -122,26 +153,52 @@ Type=simple User=tranquil-pds Group=tranquil-pds EnvironmentFile=/etc/tranquil-pds/tranquil-pds.env -Environment=FRONTEND_DIR=/var/lib/tranquil-pds/frontend ExecStart=/usr/local/bin/tranquil-pds Restart=always RestartSec=5 [Install] WantedBy=multi-user.target EOF + systemctl daemon-reload systemctl enable tranquil-pds systemctl start tranquil-pds ``` -## 11. Install and Configure nginx + +## Install and Configure nginx + ```bash apt install -y nginx certbot python3-certbot-nginx + cat > /etc/nginx/sites-available/tranquil-pds << 'EOF' server { listen 80; listen [::]:80; - server_name pds.example.com; + server_name pds.example.com *.pds.example.com; + + location /.well-known/acme-challenge/ { + root /var/www/acme; + } + location / { + return 301 https://$host$request_uri; + } +} + +server { + listen 443 ssl; + listen [::]:443 ssl; + http2 on; + server_name pds.example.com *.pds.example.com; + + ssl_certificate /etc/letsencrypt/live/pds.example.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/pds.example.com/privkey.pem; + + client_max_body_size 10G; + + root /var/www/tranquil-pds; + + location /xrpc/ { proxy_pass http://127.0.0.1:3000; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; @@ -151,16 +208,94 @@ server { proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_read_timeout 86400; + proxy_send_timeout 86400; + proxy_buffering off; + proxy_request_buffering off; + } + + location /oauth/ { + proxy_pass http://127.0.0.1:3000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 300; + proxy_send_timeout 300; + } + + location /.well-known/ { + proxy_pass http://127.0.0.1:3000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location = /metrics { + proxy_pass http://127.0.0.1:3000; + proxy_http_version 1.1; + proxy_set_header Host $host; + } + + location = /health { + proxy_pass http://127.0.0.1:3000; + proxy_http_version 1.1; + proxy_set_header Host $host; + } + + location = /robots.txt { + proxy_pass http://127.0.0.1:3000; + proxy_http_version 1.1; + proxy_set_header Host $host; + } + + location = /logo { + proxy_pass http://127.0.0.1:3000; + proxy_http_version 1.1; + proxy_set_header Host $host; + } + + location ~ ^/u/[^/]+/did\.json$ { + proxy_pass http://127.0.0.1:3000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /assets/ { + expires 1y; + add_header Cache-Control "public, immutable"; + try_files $uri =404; + } + + location /app/ { + try_files $uri $uri/ /index.html; + } + + location = / { + try_files /homepage.html /index.html; + } + + location / { + try_files $uri $uri/ /index.html; } } EOF -ln -s /etc/nginx/sites-available/tranquil-pds /etc/nginx/sites-enabled/ + +ln -sf /etc/nginx/sites-available/tranquil-pds /etc/nginx/sites-enabled/ rm -f /etc/nginx/sites-enabled/default +mkdir -p /var/www/acme nginx -t systemctl reload nginx ``` -## 12. Obtain Wildcard SSL Certificate -User handles are served as subdomains (e.g., `alice.pds.example.com`), so you need a wildcard certificate. + +## Obtain Wildcard SSL Certificate + +User handles are served as subdomains (eg., `alice.pds.example.com`), so you need a wildcard certificate. Wildcard certs require DNS-01 validation. If your DNS provider has a certbot plugin: ```bash @@ -175,10 +310,16 @@ For manual DNS validation (works with any provider): certbot certonly --manual --preferred-challenges dns \ -d pds.example.com -d '*.pds.example.com' ``` + Follow the prompts to add TXT records to your DNS. Note: manual mode doesn't auto-renew. -After obtaining the cert, update nginx to use it and reload. -## 13. Configure Firewall +After obtaining the cert, reload nginx: +```bash +systemctl reload nginx +``` + +## Configure Firewall + ```bash apt install -y ufw ufw allow ssh @@ -186,17 +327,22 @@ ufw allow 80/tcp ufw allow 443/tcp ufw enable ``` -## 14. Verify Installation + +## Verify Installation + ```bash systemctl status tranquil-pds curl -s https://pds.example.com/xrpc/_health | jq curl -s https://pds.example.com/.well-known/atproto-did ``` + ## Maintenance + View logs: ```bash journalctl -u tranquil-pds -f ``` + Update Tranquil PDS: ```bash cd /opt/tranquil-pds @@ -205,10 +351,11 @@ cd frontend && deno task build && cd .. cargo build --release systemctl stop tranquil-pds cp target/release/tranquil-pds /usr/local/bin/ -cp -r frontend/dist /var/lib/tranquil-pds/frontend +cp -r frontend/dist/* /var/www/tranquil-pds/ DATABASE_URL="postgres://tranquil_pds:your-secure-password@localhost:5432/pds" sqlx migrate run systemctl start tranquil-pds ``` + Backup database: ```bash sudo -u postgres pg_dump pds > /var/backups/pds-$(date +%Y%m%d).sql @@ -216,10 +363,10 @@ sudo -u postgres pg_dump pds > /var/backups/pds-$(date +%Y%m%d).sql ## Custom Homepage -Drop a `homepage.html` in `/var/lib/tranquil-pds/frontend/` and it becomes your landing page. Go nuts with it. Account dashboard is at `/app/` so you won't break anything. +Drop a `homepage.html` in `/var/www/tranquil-pds/` and it becomes your landing page. Account dashboard is at `/app/` so you won't break anything. ```bash -cat > /var/lib/tranquil-pds/frontend/homepage.html << 'EOF' +cat > /var/www/tranquil-pds/homepage.html << 'EOF' diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..6169540 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,9 @@ +FROM denoland/deno:alpine AS builder +WORKDIR /app +COPY . ./ +RUN deno task build + +FROM nginx:1.29-alpine +COPY --from=builder /app/dist /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 80 diff --git a/frontend/nginx-quadlet.conf b/frontend/nginx-quadlet.conf new file mode 100644 index 0000000..84a39e4 --- /dev/null +++ b/frontend/nginx-quadlet.conf @@ -0,0 +1,38 @@ +server { + listen 8080; + listen [::]:8080; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + gzip on; + gzip_vary on; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml; + + location = /oauth/client-metadata.json { + default_type application/json; + sub_filter_once off; + sub_filter_types application/json; + sub_filter '__PDS_HOSTNAME__' $host; + try_files /oauth/client-metadata.json =404; + } + + location /assets/ { + expires 1y; + add_header Cache-Control "public, immutable"; + try_files $uri =404; + } + + location = / { + try_files /homepage.html /index.html; + } + + location /app/ { + try_files $uri $uri/ /index.html; + } + + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..d8363a2 --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,38 @@ +server { + listen 80; + listen [::]:80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + gzip on; + gzip_vary on; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml; + + location = /oauth/client-metadata.json { + default_type application/json; + sub_filter_once off; + sub_filter_types application/json; + sub_filter '__PDS_HOSTNAME__' $host; + try_files /oauth/client-metadata.json =404; + } + + location /assets/ { + expires 1y; + add_header Cache-Control "public, immutable"; + try_files $uri =404; + } + + location = / { + try_files /homepage.html /index.html; + } + + location /app/ { + try_files $uri $uri/ /index.html; + } + + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/frontend/public/homepage.html b/frontend/public/homepage.html index 4917365..5b3edc2 100644 --- a/frontend/public/homepage.html +++ b/frontend/public/homepage.html @@ -440,7 +440,7 @@ Join This Server

Real security

- Sign in with passkeys, add two-factor authentication, set up - backup codes, and mark devices you trust. Your account stays - yours. + Sign in with passkeys or SSO, add two-factor authentication, + set up backup codes, and mark devices you trust. Your account + stays yours.

@@ -546,7 +546,7 @@
Join This Server /dev/null + cgr.dev/chainguard/minio:latest server /data >/dev/null echo "Starting Valkey..." $CONTAINER_CMD run -d \ --name "${CONTAINER_PREFIX}-valkey" \ -P \ --label tranquil_pds_test=true \ - valkey/valkey:8-alpine >/dev/null + valkey/valkey:9-alpine >/dev/null echo "Waiting for services to be ready..." sleep 2 PG_PORT=$($CONTAINER_CMD port "${CONTAINER_PREFIX}-postgres" 5432 | head -1 | cut -d: -f2) @@ -86,10 +86,10 @@ start_infra() { echo "Creating MinIO buckets..." $CONTAINER_CMD run --rm --network host \ -e MC_HOST_minio="http://minioadmin:minioadmin@127.0.0.1:${MINIO_PORT}" \ - minio/mc:latest mb minio/test-bucket --ignore-existing >/dev/null 2>&1 || true + cgr.dev/chainguard/minio-client:latest-dev mb minio/test-bucket --ignore-existing >/dev/null 2>&1 || true $CONTAINER_CMD run --rm --network host \ -e MC_HOST_minio="http://minioadmin:minioadmin@127.0.0.1:${MINIO_PORT}" \ - minio/mc:latest mb minio/test-backups --ignore-existing >/dev/null 2>&1 || true + cgr.dev/chainguard/minio-client:latest-dev mb minio/test-backups --ignore-existing >/dev/null 2>&1 || true cat > "$INFRA_FILE" << EOF export DATABASE_URL="postgres://postgres:postgres@127.0.0.1:${PG_PORT}/postgres" export TEST_DB_PORT="${PG_PORT}"