The frontend is no longer served by the backend.
+2
-4
.env.example
+2
-4
.env.example
···
140
140
# =============================================================================
141
141
# If configured, moderation reports will be proxied to this service
142
142
# instead of being stored locally. The service should implement the
143
-
# com.atproto.moderation.createReport endpoint (e.g., Bluesky's Ozone).
143
+
# com.atproto.moderation.createReport endpoint (eg., Bluesky's Ozone).
144
144
# Both URL and DID must be set for proxying to be enabled.
145
145
# REPORT_SERVICE_URL=https://mod.bsky.app
146
146
# REPORT_SERVICE_DID=did:plc:ar7c4by46qjdydhdevvrndac
···
148
148
# Age Assurance Override
149
149
# =============================================================================
150
150
# Enable this if you have separately assured the ages of your users
151
-
# (e.g., through your own age verification process). When enabled, the PDS
151
+
# (eg., through your own age verification process). When enabled, the PDS
152
152
# will return "assured" status for age assurance checks instead of proxying
153
153
# to the appview. This helps migrated users avoid the age assurance
154
154
# catch-22 on bsky.app.
···
158
158
# =============================================================================
159
159
# Allow HTTP for proxy requests (development only)
160
160
# ALLOW_HTTP_PROXY=1
161
-
# Custom frontend directory (defaults to ./frontend/dist)
162
-
# FRONTEND_DIR=/path/to/frontend/dist
163
161
# =============================================================================
164
162
# SSO / Social Login
165
163
# =============================================================================
-28
Cargo.lock
-28
Cargo.lock
···
2591
2591
"pin-project-lite",
2592
2592
]
2593
2593
2594
-
[[package]]
2595
-
name = "http-range-header"
2596
-
version = "0.4.2"
2597
-
source = "registry+https://github.com/rust-lang/crates.io-index"
2598
-
checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c"
2599
-
2600
2594
[[package]]
2601
2595
name = "httparse"
2602
2596
version = "1.10.1"
···
3477
3471
source = "registry+https://github.com/rust-lang/crates.io-index"
3478
3472
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
3479
3473
3480
-
[[package]]
3481
-
name = "mime_guess"
3482
-
version = "2.0.5"
3483
-
source = "registry+https://github.com/rust-lang/crates.io-index"
3484
-
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
3485
-
dependencies = [
3486
-
"mime",
3487
-
"unicase",
3488
-
]
3489
-
3490
3474
[[package]]
3491
3475
name = "minimal-lexical"
3492
3476
version = "0.2.1"
···
5850
5834
"http 1.4.0",
5851
5835
"http-body 1.0.1",
5852
5836
"http-body-util",
5853
-
"http-range-header",
5854
-
"httpdate",
5855
5837
"iri-string",
5856
-
"mime",
5857
-
"mime_guess",
5858
-
"percent-encoding",
5859
5838
"pin-project-lite",
5860
5839
"tokio",
5861
5840
"tokio-util",
5862
5841
"tower",
5863
5842
"tower-layer",
5864
5843
"tower-service",
5865
-
"tracing",
5866
5844
]
5867
5845
5868
5846
[[package]]
···
6241
6219
source = "registry+https://github.com/rust-lang/crates.io-index"
6242
6220
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
6243
6221
6244
-
[[package]]
6245
-
name = "unicase"
6246
-
version = "2.8.1"
6247
-
source = "registry+https://github.com/rust-lang/crates.io-index"
6248
-
checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539"
6249
-
6250
6222
[[package]]
6251
6223
name = "unicode-bidi"
6252
6224
version = "0.3.18"
+1
-1
Cargo.toml
+1
-1
Cargo.toml
···
90
90
tokio-tungstenite = { version = "0.28", features = ["native-tls"] }
91
91
totp-rs = { version = "5", features = ["qr"] }
92
92
tower = "0.5"
93
-
tower-http = { version = "0.6", features = ["fs", "cors"] }
93
+
tower-http = { version = "0.6", features = ["cors"] }
94
94
tower-layer = "0.3"
95
95
tracing = "0.1"
96
96
tracing-subscriber = "0.3"
+5
-13
Dockerfile
+5
-13
Dockerfile
···
1
-
FROM denoland/deno:alpine AS frontend-builder
2
-
WORKDIR /frontend
3
-
COPY frontend/ ./
4
-
RUN deno task build
5
-
6
1
FROM rust:1.92-alpine AS builder
7
-
RUN apk add ca-certificates openssl openssl-dev openssl-libs-static pkgconfig musl-dev
2
+
RUN apk add --no-cache ca-certificates openssl openssl-dev openssl-libs-static pkgconfig musl-dev
8
3
WORKDIR /app
9
4
COPY Cargo.toml Cargo.lock ./
10
-
COPY src ./src
11
-
COPY tests ./tests
12
-
COPY migrations ./migrations
5
+
COPY crates ./crates
13
6
COPY .sqlx ./.sqlx
7
+
COPY migrations ./crates/tranquil-pds/migrations
14
8
RUN --mount=type=cache,target=/usr/local/cargo/registry \
15
9
--mount=type=cache,target=/app/target \
16
-
cargo build --release && \
10
+
SQLX_OFFLINE=true cargo build --release -p tranquil-pds && \
17
11
cp target/release/tranquil-pds /tmp/tranquil-pds
18
12
19
13
FROM alpine:3.23
20
14
RUN apk add --no-cache msmtp ca-certificates && ln -sf /usr/bin/msmtp /usr/sbin/sendmail
21
15
COPY --from=builder /tmp/tranquil-pds /usr/local/bin/tranquil-pds
22
-
COPY --from=builder /app/migrations /app/migrations
23
-
COPY --from=frontend-builder /frontend/dist /app/frontend/dist
16
+
COPY migrations /app/migrations
24
17
WORKDIR /app
25
18
ENV SERVER_HOST=0.0.0.0
26
19
ENV SERVER_PORT=3000
27
-
ENV FRONTEND_DIR=/app/frontend/dist
28
20
EXPOSE 3000
29
21
CMD ["tranquil-pds"]
+2
-2
README.md
+2
-2
README.md
···
12
12
13
13
## What's different about Tranquil PDS
14
14
15
-
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.
15
+
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.
16
16
17
17
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.
18
18
···
45
45
46
46
```bash
47
47
cp .env.prod.example .env.prod
48
-
podman-compose -f docker-compose.prod.yml up -d
48
+
podman-compose -f docker-compose.prod.yaml up -d
49
49
```
50
50
51
51
### Installation Guides
+5
-3
crates/tranquil-oauth/src/client.rs
+5
-3
crates/tranquil-oauth/src/client.rs
···
83
83
.connect_timeout(std::time::Duration::from_secs(10))
84
84
.pool_max_idle_per_host(10)
85
85
.pool_idle_timeout(std::time::Duration::from_secs(90))
86
-
.user_agent(
87
-
"Tranquil-PDS/1.0 (ATProto; +https://tangled.org/lewis.moe/bspds-sandbox)",
88
-
)
86
+
.user_agent(concat!(
87
+
"Tranquil-PDS/",
88
+
env!("CARGO_PKG_VERSION"),
89
+
" (ATProto; +https://tangled.org/tranquil.farm/tranquil-pds)"
90
+
))
89
91
.build()
90
92
.unwrap_or_else(|_| Client::new()),
91
93
cache_ttl_secs,
+2
-35
crates/tranquil-pds/src/lib.rs
+2
-35
crates/tranquil-pds/src/lib.rs
···
38
38
pub use sync::util::AccountStatus;
39
39
use tower::ServiceBuilder;
40
40
use tower_http::cors::{Any, CorsLayer};
41
-
use tower_http::services::{ServeDir, ServeFile};
42
41
pub use types::{AccountState, AtIdentifier, AtUri, Did, Handle, Nsid, Rkey};
43
42
44
43
pub fn app(state: AppState) -> Router {
···
525
524
526
525
let oauth_router = Router::new()
527
526
.route("/jwks", get(oauth::endpoints::oauth_jwks))
528
-
.route(
529
-
"/client-metadata.json",
530
-
get(oauth::endpoints::frontend_client_metadata),
531
-
)
532
527
.route("/par", post(oauth::endpoints::pushed_authorization_request))
533
528
.route("/authorize", get(oauth::endpoints::authorize_get))
534
529
.route("/authorize", post(oauth::endpoints::authorize_post))
···
612
607
get(oauth::endpoints::oauth_authorization_server),
613
608
);
614
609
615
-
let router = Router::new()
610
+
Router::new()
616
611
.nest_service("/xrpc", xrpc_service)
617
612
.nest("/oauth", oauth_router)
618
613
.nest("/.well-known", well_known_router)
···
644
639
"atproto-content-labelers".parse().unwrap(),
645
640
]),
646
641
)
647
-
.with_state(state);
648
-
649
-
let frontend_dir =
650
-
std::env::var("FRONTEND_DIR").unwrap_or_else(|_| "./frontend/dist".to_string());
651
-
if std::path::Path::new(&frontend_dir)
652
-
.join("index.html")
653
-
.exists()
654
-
{
655
-
let index_path = format!("{}/index.html", frontend_dir);
656
-
let homepage_path = format!("{}/homepage.html", frontend_dir);
657
-
658
-
let homepage_exists = std::path::Path::new(&homepage_path).exists();
659
-
let homepage_file = if homepage_exists {
660
-
homepage_path
661
-
} else {
662
-
index_path.clone()
663
-
};
664
-
665
-
let spa_router = Router::new().fallback_service(ServeFile::new(&index_path));
666
-
667
-
let serve_dir = ServeDir::new(&frontend_dir).not_found_service(ServeFile::new(&index_path));
668
-
669
-
return router
670
-
.route_service("/", ServeFile::new(&homepage_file))
671
-
.nest("/app", spa_router)
672
-
.fallback_service(serve_dir);
673
-
}
674
-
675
-
router
642
+
.with_state(state)
676
643
}
-52
crates/tranquil-pds/src/oauth/endpoints/metadata.rs
-52
crates/tranquil-pds/src/oauth/endpoints/metadata.rs
···
82
82
"transition:generic".to_string(),
83
83
"transition:chat.bsky".to_string(),
84
84
"transition:email".to_string(),
85
-
"repo:*".to_string(),
86
-
"repo:*?action=create".to_string(),
87
-
"repo:*?action=read".to_string(),
88
-
"repo:*?action=update".to_string(),
89
-
"repo:*?action=delete".to_string(),
90
-
"blob:*/*".to_string(),
91
-
"rpc:*".to_string(),
92
-
"account:*".to_string(),
93
-
"account:*?action=read".to_string(),
94
-
"account:*?action=write".to_string(),
95
-
"identity:*".to_string(),
96
85
]),
97
86
response_types_supported: vec!["code".to_string()],
98
87
response_modes_supported: Some(vec!["query".to_string(), "fragment".to_string()]),
···
142
131
};
143
132
Json(create_jwk_set(vec![server_key]))
144
133
}
145
-
146
-
#[derive(Debug, Serialize, Deserialize)]
147
-
pub struct FrontendClientMetadata {
148
-
pub client_id: String,
149
-
pub client_name: String,
150
-
pub client_uri: String,
151
-
pub redirect_uris: Vec<String>,
152
-
pub grant_types: Vec<String>,
153
-
pub response_types: Vec<String>,
154
-
pub scope: String,
155
-
pub token_endpoint_auth_method: String,
156
-
pub application_type: String,
157
-
pub dpop_bound_access_tokens: bool,
158
-
}
159
-
160
-
pub async fn frontend_client_metadata(
161
-
State(_state): State<AppState>,
162
-
) -> Json<FrontendClientMetadata> {
163
-
let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
164
-
let base_url = format!("https://{}", pds_hostname);
165
-
let client_id = format!("{}/oauth/client-metadata.json", base_url);
166
-
Json(FrontendClientMetadata {
167
-
client_id,
168
-
client_name: "PDS Account Manager".to_string(),
169
-
client_uri: base_url.clone(),
170
-
redirect_uris: vec![
171
-
format!("{}/app/", base_url),
172
-
format!("{}/app/migrate", base_url),
173
-
],
174
-
grant_types: vec![
175
-
"authorization_code".to_string(),
176
-
"refresh_token".to_string(),
177
-
],
178
-
response_types: vec!["code".to_string()],
179
-
scope: "atproto transition:generic repo:* blob:*/* rpc:* rpc:com.atproto.server.createAccount?aud=* account:*?action=manage identity:*"
180
-
.to_string(),
181
-
token_endpoint_auth_method: "none".to_string(),
182
-
application_type: "web".to_string(),
183
-
dpop_bound_access_tokens: true,
184
-
})
185
-
}
+1
-1
crates/tranquil-pds/tests/common/mod.rs
+1
-1
crates/tranquil-pds/tests/common/mod.rs
···
162
162
163
163
#[cfg(not(feature = "external-infra"))]
164
164
async fn setup_with_testcontainers() -> String {
165
-
let s3_container = GenericImage::new("minio/minio", "latest")
165
+
let s3_container = GenericImage::new("cgr.dev/chainguard/minio", "latest")
166
166
.with_exposed_port(ContainerPort::Tcp(9000))
167
167
.with_env_var("MINIO_ROOT_USER", "minioadmin")
168
168
.with_env_var("MINIO_ROOT_PASSWORD", "minioadmin")
-114
crates/tranquil-pds/tests/oauth_client_metadata.rs
-114
crates/tranquil-pds/tests/oauth_client_metadata.rs
···
1
-
mod common;
2
-
use common::*;
3
-
use reqwest::StatusCode;
4
-
use serde_json::Value;
5
-
6
-
#[tokio::test]
7
-
async fn test_frontend_client_metadata_returns_valid_json() {
8
-
let client = client();
9
-
let res = client
10
-
.get(format!("{}/oauth/client-metadata.json", base_url().await))
11
-
.send()
12
-
.await
13
-
.expect("Failed to send request");
14
-
assert_eq!(res.status(), StatusCode::OK);
15
-
let body: Value = res.json().await.expect("Should return valid JSON");
16
-
assert!(
17
-
body["client_id"].as_str().is_some(),
18
-
"Should have client_id"
19
-
);
20
-
assert!(
21
-
body["client_name"].as_str().is_some(),
22
-
"Should have client_name"
23
-
);
24
-
assert!(
25
-
body["redirect_uris"].as_array().is_some(),
26
-
"Should have redirect_uris"
27
-
);
28
-
assert!(
29
-
body["grant_types"].as_array().is_some(),
30
-
"Should have grant_types"
31
-
);
32
-
assert!(
33
-
body["response_types"].as_array().is_some(),
34
-
"Should have response_types"
35
-
);
36
-
assert!(body["scope"].as_str().is_some(), "Should have scope");
37
-
assert!(
38
-
body["token_endpoint_auth_method"].as_str().is_some(),
39
-
"Should have token_endpoint_auth_method"
40
-
);
41
-
}
42
-
43
-
#[tokio::test]
44
-
async fn test_frontend_client_metadata_correct_values() {
45
-
let client = client();
46
-
let res = client
47
-
.get(format!("{}/oauth/client-metadata.json", base_url().await))
48
-
.send()
49
-
.await
50
-
.expect("Failed to send request");
51
-
assert_eq!(res.status(), StatusCode::OK);
52
-
let body: Value = res.json().await.unwrap();
53
-
let client_id = body["client_id"].as_str().unwrap();
54
-
assert!(
55
-
client_id.ends_with("/oauth/client-metadata.json"),
56
-
"client_id should end with /oauth/client-metadata.json"
57
-
);
58
-
let grant_types = body["grant_types"].as_array().unwrap();
59
-
let grant_strs: Vec<&str> = grant_types.iter().filter_map(|v| v.as_str()).collect();
60
-
assert!(
61
-
grant_strs.contains(&"authorization_code"),
62
-
"Should support authorization_code grant"
63
-
);
64
-
assert!(
65
-
grant_strs.contains(&"refresh_token"),
66
-
"Should support refresh_token grant"
67
-
);
68
-
let response_types = body["response_types"].as_array().unwrap();
69
-
let response_strs: Vec<&str> = response_types.iter().filter_map(|v| v.as_str()).collect();
70
-
assert!(
71
-
response_strs.contains(&"code"),
72
-
"Should support code response type"
73
-
);
74
-
assert_eq!(
75
-
body["token_endpoint_auth_method"].as_str(),
76
-
Some("none"),
77
-
"Should be public client (none auth)"
78
-
);
79
-
assert_eq!(
80
-
body["application_type"].as_str(),
81
-
Some("web"),
82
-
"Should be web application"
83
-
);
84
-
assert_eq!(
85
-
body["dpop_bound_access_tokens"].as_bool(),
86
-
Some(true),
87
-
"AT Protocol requires DPoP-bound access tokens"
88
-
);
89
-
let scope = body["scope"].as_str().unwrap();
90
-
assert!(scope.contains("atproto"), "Scope should include atproto");
91
-
}
92
-
93
-
#[tokio::test]
94
-
async fn test_frontend_client_metadata_redirect_uri_matches_client_uri() {
95
-
let client = client();
96
-
let res = client
97
-
.get(format!("{}/oauth/client-metadata.json", base_url().await))
98
-
.send()
99
-
.await
100
-
.expect("Failed to send request");
101
-
assert_eq!(res.status(), StatusCode::OK);
102
-
let body: Value = res.json().await.unwrap();
103
-
let client_uri = body["client_uri"].as_str().unwrap();
104
-
let redirect_uris = body["redirect_uris"].as_array().unwrap();
105
-
assert!(
106
-
!redirect_uris.is_empty(),
107
-
"Should have at least one redirect URI"
108
-
);
109
-
let redirect_uri = redirect_uris[0].as_str().unwrap();
110
-
assert!(
111
-
redirect_uri.starts_with(client_uri),
112
-
"Redirect URI should be on same origin as client_uri"
113
-
);
114
-
}
+92
-3
deploy/nginx/nginx-quadlet.conf
+92
-3
deploy/nginx/nginx-quadlet.conf
···
1
1
worker_processes auto;
2
2
error_log /var/log/nginx/error.log warn;
3
+
3
4
events {
4
5
worker_connections 4096;
5
6
}
7
+
6
8
http {
7
9
include /etc/nginx/mime.types;
8
10
default_type application/octet-stream;
9
11
access_log /var/log/nginx/access.log;
12
+
10
13
sendfile on;
11
14
keepalive_timeout 65;
15
+
12
16
gzip on;
13
17
gzip_types text/plain text/css application/json application/javascript text/xml application/xml;
18
+
14
19
ssl_protocols TLSv1.2 TLSv1.3;
15
20
ssl_prefer_server_ciphers off;
16
21
ssl_session_cache shared:SSL:10m;
17
22
ssl_stapling on;
18
23
ssl_stapling_verify on;
24
+
19
25
server {
20
26
listen 80;
21
27
listen [::]:80;
22
28
server_name _;
29
+
23
30
location /.well-known/acme-challenge/ {
24
31
root /var/www/acme;
25
32
}
33
+
26
34
location / {
27
35
return 301 https://$host$request_uri;
28
36
}
29
37
}
38
+
30
39
server {
31
-
listen 443 ssl http2;
32
-
listen [::]:443 ssl http2;
40
+
listen 443 ssl;
41
+
listen [::]:443 ssl;
42
+
http2 on;
33
43
server_name _;
44
+
34
45
ssl_certificate /etc/nginx/certs/fullchain.pem;
35
46
ssl_certificate_key /etc/nginx/certs/privkey.pem;
47
+
36
48
client_max_body_size 10G;
37
-
location / {
49
+
50
+
location /xrpc/ {
38
51
proxy_pass http://127.0.0.1:3000;
39
52
proxy_http_version 1.1;
40
53
proxy_set_header Upgrade $http_upgrade;
···
46
59
proxy_read_timeout 86400;
47
60
proxy_send_timeout 86400;
48
61
proxy_buffering off;
62
+
proxy_request_buffering off;
63
+
}
64
+
65
+
location = /oauth/client-metadata.json {
66
+
proxy_pass http://127.0.0.1:8080;
67
+
proxy_http_version 1.1;
68
+
proxy_set_header Host $host;
69
+
proxy_set_header Accept-Encoding "";
70
+
sub_filter_once off;
71
+
sub_filter_types application/json;
72
+
sub_filter '__PDS_HOSTNAME__' $host;
73
+
}
74
+
75
+
location /oauth/ {
76
+
proxy_pass http://127.0.0.1:3000;
77
+
proxy_http_version 1.1;
78
+
proxy_set_header Host $host;
79
+
proxy_set_header X-Real-IP $remote_addr;
80
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
81
+
proxy_set_header X-Forwarded-Proto $scheme;
82
+
proxy_read_timeout 300;
83
+
proxy_send_timeout 300;
84
+
}
85
+
86
+
location /.well-known/ {
87
+
proxy_pass http://127.0.0.1:3000;
88
+
proxy_http_version 1.1;
89
+
proxy_set_header Host $host;
90
+
proxy_set_header X-Real-IP $remote_addr;
91
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
92
+
proxy_set_header X-Forwarded-Proto $scheme;
93
+
}
94
+
95
+
location = /metrics {
96
+
proxy_pass http://127.0.0.1:3000;
97
+
proxy_http_version 1.1;
98
+
proxy_set_header Host $host;
99
+
proxy_set_header X-Real-IP $remote_addr;
100
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
101
+
proxy_set_header X-Forwarded-Proto $scheme;
102
+
}
103
+
104
+
location = /health {
105
+
proxy_pass http://127.0.0.1:3000;
106
+
proxy_http_version 1.1;
107
+
proxy_set_header Host $host;
108
+
}
109
+
110
+
location = /robots.txt {
111
+
proxy_pass http://127.0.0.1:3000;
112
+
proxy_http_version 1.1;
113
+
proxy_set_header Host $host;
114
+
}
115
+
116
+
location = /logo {
117
+
proxy_pass http://127.0.0.1:3000;
118
+
proxy_http_version 1.1;
119
+
proxy_set_header Host $host;
120
+
}
121
+
122
+
location ~ ^/u/[^/]+/did\.json$ {
123
+
proxy_pass http://127.0.0.1:3000;
124
+
proxy_http_version 1.1;
125
+
proxy_set_header Host $host;
126
+
proxy_set_header X-Real-IP $remote_addr;
127
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
128
+
proxy_set_header X-Forwarded-Proto $scheme;
129
+
}
130
+
131
+
location / {
132
+
proxy_pass http://127.0.0.1:8080;
133
+
proxy_http_version 1.1;
134
+
proxy_set_header Host $host;
135
+
proxy_set_header X-Real-IP $remote_addr;
136
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
137
+
proxy_set_header X-Forwarded-Proto $scheme;
49
138
}
50
139
}
51
140
}
-1
deploy/quadlets/tranquil-pds-app.container
-1
deploy/quadlets/tranquil-pds-app.container
+21
deploy/quadlets/tranquil-pds-frontend.container
+21
deploy/quadlets/tranquil-pds-frontend.container
···
1
+
[Unit]
2
+
Description=Tranquil PDS frontend
3
+
After=tranquil-pds-app.service
4
+
5
+
[Container]
6
+
ContainerName=tranquil-pds-frontend
7
+
Image=localhost/tranquil-pds-frontend:latest
8
+
Pod=tranquil-pds.pod
9
+
Volume=/opt/tranquil-pds/frontend/nginx-quadlet.conf:/etc/nginx/conf.d/default.conf:ro,Z
10
+
HealthCmd=wget -q --spider http://localhost:8080/
11
+
HealthInterval=30s
12
+
HealthTimeout=10s
13
+
HealthRetries=3
14
+
HealthStartPeriod=5s
15
+
16
+
[Service]
17
+
Restart=always
18
+
RestartSec=10
19
+
20
+
[Install]
21
+
WantedBy=default.target
+1
-6
deploy/quadlets/tranquil-pds-minio.container
+1
-6
deploy/quadlets/tranquil-pds-minio.container
···
2
2
Description=Tranquil PDS minio object storage
3
3
[Container]
4
4
ContainerName=tranquil-pds-minio
5
-
Image=docker.io/minio/minio:RELEASE.2025-10-15T17-29-55Z
5
+
Image=cgr.dev/chainguard/minio:latest
6
6
Pod=tranquil-pds.pod
7
7
Environment=MINIO_ROOT_USER=minioadmin
8
8
Secret=tranquil-pds-minio-password,type=env,target=MINIO_ROOT_PASSWORD
9
9
Volume=/srv/tranquil-pds/minio:/data:Z
10
10
Exec=server /data --console-address :9001
11
-
HealthCmd=curl -f http://localhost:9000/minio/health/live || exit 1
12
-
HealthInterval=30s
13
-
HealthTimeout=10s
14
-
HealthRetries=3
15
-
HealthStartPeriod=10s
16
11
[Service]
17
12
Restart=always
18
13
RestartSec=10
+1
-1
deploy/quadlets/tranquil-pds-nginx.container
+1
-1
deploy/quadlets/tranquil-pds-nginx.container
+40
-18
docker-compose.prod.yml
docker-compose.prod.yaml
+40
-18
docker-compose.prod.yml
docker-compose.prod.yaml
···
5
5
dockerfile: Dockerfile
6
6
image: tranquil-pds:latest
7
7
restart: unless-stopped
8
-
ports:
9
-
- "127.0.0.1:3000:3000"
10
8
environment:
11
9
SERVER_HOST: "0.0.0.0"
12
10
SERVER_PORT: "3000"
···
22
20
DPOP_SECRET: "${DPOP_SECRET:?DPOP_SECRET is required (min 32 chars)}"
23
21
MASTER_KEY: "${MASTER_KEY:?MASTER_KEY is required (min 32 chars)}"
24
22
CRAWLERS: "${CRAWLERS:-https://bsky.network}"
25
-
FRONTEND_DIR: "/app/frontend/dist"
26
23
depends_on:
27
24
db:
28
25
condition: service_healthy
···
42
39
memory: 1G
43
40
reservations:
44
41
memory: 256M
42
+
43
+
frontend:
44
+
build:
45
+
context: ./frontend
46
+
dockerfile: Dockerfile
47
+
image: tranquil-pds-frontend:latest
48
+
restart: unless-stopped
49
+
healthcheck:
50
+
test: ["CMD", "wget", "-q", "--spider", "http://localhost:80/"]
51
+
interval: 30s
52
+
timeout: 10s
53
+
retries: 3
54
+
start_period: 5s
55
+
deploy:
56
+
resources:
57
+
limits:
58
+
memory: 128M
59
+
reservations:
60
+
memory: 32M
61
+
45
62
db:
46
63
image: postgres:18-alpine
47
64
restart: unless-stopped
···
63
80
memory: 512M
64
81
reservations:
65
82
memory: 128M
83
+
66
84
minio:
67
-
image: minio/minio:RELEASE.2025-10-15T17-29-55Z
85
+
image: cgr.dev/chainguard/minio:latest
68
86
restart: unless-stopped
69
87
command: server /data --console-address ":9001"
70
88
environment:
···
72
90
MINIO_ROOT_PASSWORD: "${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD is required}"
73
91
volumes:
74
92
- minio_data:/data
75
-
healthcheck:
76
-
test: ["CMD", "mc", "ready", "local"]
77
-
interval: 30s
78
-
timeout: 10s
79
-
retries: 3
80
-
start_period: 10s
81
93
deploy:
82
94
resources:
83
95
limits:
84
96
memory: 512M
85
97
reservations:
86
98
memory: 128M
99
+
87
100
minio-init:
88
-
image: minio/mc:RELEASE.2025-07-16T15-35-03Z
101
+
image: cgr.dev/chainguard/minio-client:latest-dev
89
102
depends_on:
90
-
minio:
91
-
condition: service_healthy
103
+
- minio
92
104
entrypoint: >
93
105
/bin/sh -c "
94
-
mc alias set local http://minio:9000 $${MINIO_ROOT_USER} $${MINIO_ROOT_PASSWORD};
106
+
for i in 1 2 3 4 5 6 7 8 9 10; do
107
+
mc alias set local http://minio:9000 $${MINIO_ROOT_USER} $${MINIO_ROOT_PASSWORD} && break;
108
+
echo 'Waiting for minio...'; sleep 2;
109
+
done;
95
110
mc mb --ignore-existing local/pds-blobs;
111
+
mc mb --ignore-existing local/pds-backups;
96
112
mc anonymous set none local/pds-blobs;
97
113
exit 0;
98
114
"
99
115
environment:
100
116
MINIO_ROOT_USER: "${MINIO_ROOT_USER:-minioadmin}"
101
117
MINIO_ROOT_PASSWORD: "${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD is required}"
118
+
102
119
valkey:
103
120
image: valkey/valkey:9-alpine
104
121
restart: unless-stopped
···
117
134
memory: 300M
118
135
reservations:
119
136
memory: 64M
137
+
120
138
nginx:
121
-
image: nginx:1.28-alpine
139
+
image: nginx:1.29-alpine
122
140
restart: unless-stopped
123
141
ports:
124
142
- "80:80"
125
143
- "443:443"
126
144
volumes:
127
-
- ./nginx.prod.conf:/etc/nginx/nginx.conf:ro
145
+
- ./nginx.frontend.conf:/etc/nginx/nginx.conf:ro
128
146
- ./certs:/etc/nginx/certs:ro
129
147
- acme_challenge:/var/www/acme:ro
130
148
depends_on:
131
149
- tranquil-pds
150
+
- frontend
132
151
healthcheck:
133
152
test: ["CMD", "nginx", "-t"]
134
153
interval: 30s
135
154
timeout: 10s
136
155
retries: 3
156
+
137
157
certbot:
138
158
image: certbot/certbot:v5.2.2
139
159
volumes:
140
160
- ./certs:/etc/letsencrypt
141
161
- acme_challenge:/var/www/acme
142
162
entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew --webroot -w /var/www/acme; sleep 12h & wait $${!}; done'"
163
+
143
164
prometheus:
144
165
image: prom/prometheus:v3.8.0
145
166
restart: unless-stopped
146
167
ports:
147
168
- "127.0.0.1:9090:9090"
148
169
volumes:
149
-
- ./observability/prometheus.yml:/etc/prometheus/prometheus.yml:ro
170
+
- ./observability/prometheus.yaml:/etc/prometheus/prometheus.yaml:ro
150
171
- prometheus_data:/prometheus
151
172
command:
152
-
- '--config.file=/etc/prometheus/prometheus.yml'
173
+
- '--config.file=/etc/prometheus/prometheus.yaml'
153
174
- '--storage.tsdb.path=/prometheus'
154
175
- '--storage.tsdb.retention.time=30d'
155
176
deploy:
156
177
resources:
157
178
limits:
158
179
memory: 256M
180
+
159
181
volumes:
160
182
postgres_data:
161
183
minio_data:
+19
-4
docker-compose.yaml
+19
-4
docker-compose.yaml
···
16
16
- db
17
17
- objsto
18
18
- cache
19
+
20
+
frontend:
21
+
build:
22
+
context: ./frontend
23
+
dockerfile: Dockerfile
24
+
image: tranquil-pds-frontend
25
+
ports:
26
+
- "8080:80"
27
+
depends_on:
28
+
- app
29
+
19
30
db:
20
31
image: postgres:18-alpine
21
32
environment:
···
26
37
- "5432:5432"
27
38
volumes:
28
39
- postgres_data:/var/lib/postgresql
40
+
29
41
objsto:
30
-
image: minio/minio
42
+
image: cgr.dev/chainguard/minio:latest
31
43
ports:
32
44
- "9000:9000"
33
45
- "9001:9001"
···
37
49
volumes:
38
50
- minio_data:/data
39
51
command: server /data --console-address ":9001"
52
+
40
53
cache:
41
-
image: valkey/valkey:8-alpine
54
+
image: valkey/valkey:9-alpine
42
55
ports:
43
56
- "6379:6379"
44
57
volumes:
45
58
- valkey_data:/data
59
+
46
60
prometheus:
47
61
image: prom/prometheus:v3.8.0
48
62
ports:
49
63
- "9090:9090"
50
64
volumes:
51
-
- ./observability/prometheus.yml:/etc/prometheus/prometheus.yml:ro
65
+
- ./observability/prometheus.yaml:/etc/prometheus/prometheus.yaml:ro
52
66
- prometheus_data:/prometheus
53
67
command:
54
-
- '--config.file=/etc/prometheus/prometheus.yml'
68
+
- '--config.file=/etc/prometheus/prometheus.yaml'
55
69
- '--storage.tsdb.path=/prometheus'
56
70
depends_on:
57
71
- app
72
+
58
73
volumes:
59
74
postgres_data:
60
75
minio_data:
+183
-43
docs/install-containers.md
+183
-43
docs/install-containers.md
···
1
1
# Tranquil PDS Containerized Production Deployment
2
-
> **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.
2
+
3
3
This guide covers deploying Tranquil PDS using containers with podman.
4
+
4
5
- **Debian 13+**: Uses systemd quadlets (modern, declarative container management)
5
6
- **Alpine 3.23+**: Uses OpenRC service script with podman-compose
7
+
6
8
## Prerequisites
9
+
7
10
- A VPS with at least 2GB RAM and 20GB disk
8
11
- A domain name pointing to your server's IP
9
12
- A **wildcard TLS certificate** for `*.pds.example.com` (user handles are served as subdomains)
10
13
- Root or sudo access
14
+
11
15
## Quick Start (Docker/Podman Compose)
16
+
12
17
If you just want to get running quickly:
18
+
13
19
```sh
14
20
cp .env.example .env
15
21
```
···
18
24
19
25
Build and start:
20
26
```sh
21
-
podman-compose -f docker-compose.prod.yml up -d
27
+
podman build -t tranquil-pds:latest .
28
+
podman build -t tranquil-pds-frontend:latest ./frontend
29
+
podman-compose -f docker-compose.prod.yaml up -d
22
30
```
23
31
24
32
Get initial certificate (after DNS is configured):
25
33
```sh
26
-
podman-compose -f docker-compose.prod.yml run --rm certbot certonly \
27
-
--webroot -w /var/www/acme -d pds.example.com
28
-
podman-compose -f docker-compose.prod.yml restart nginx
34
+
podman-compose -f docker-compose.prod.yaml run --rm certbot certonly \
35
+
--webroot -w /var/www/acme -d pds.example.com -d '*.pds.example.com'
36
+
ln -sf live/pds.example.com/fullchain.pem certs/fullchain.pem
37
+
ln -sf live/pds.example.com/privkey.pem certs/privkey.pem
38
+
podman-compose -f docker-compose.prod.yaml restart nginx
29
39
```
40
+
30
41
For production setups with proper service management, continue to either the Debian or Alpine section below.
42
+
43
+
## Standalone Containers (No Compose)
44
+
45
+
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.
46
+
47
+
Build the images:
48
+
```sh
49
+
podman build -t tranquil-pds:latest .
50
+
podman build -t tranquil-pds-frontend:latest ./frontend
51
+
```
52
+
53
+
Run the backend with host networking (so it can access postgres/valkey/minio on localhost):
54
+
```sh
55
+
podman run -d --name tranquil-pds \
56
+
--network=host \
57
+
--env-file /etc/tranquil-pds/tranquil-pds.env \
58
+
tranquil-pds:latest
59
+
```
60
+
61
+
Run the frontend with port mapping (the container's nginx listens on port 80):
62
+
```sh
63
+
podman run -d --name tranquil-pds-frontend \
64
+
-p 8080:80 \
65
+
tranquil-pds-frontend:latest
66
+
```
67
+
68
+
Then configure your host nginx to proxy to both containers. Replace the static file `try_files` directives with proxy passes:
69
+
70
+
```nginx
71
+
# API routes to backend
72
+
location /xrpc/ {
73
+
proxy_pass http://127.0.0.1:3000;
74
+
# ... (see Debian guide for full proxy headers)
75
+
}
76
+
77
+
# Static routes to frontend container
78
+
location / {
79
+
proxy_pass http://127.0.0.1:8080;
80
+
proxy_http_version 1.1;
81
+
proxy_set_header Host $host;
82
+
proxy_set_header X-Real-IP $remote_addr;
83
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
84
+
proxy_set_header X-Forwarded-Proto $scheme;
85
+
}
86
+
```
87
+
88
+
See the [Debian install guide](install-debian.md) for the full nginx config with all API routes.
89
+
31
90
---
91
+
32
92
# Debian 13+ with Systemd Quadlets
93
+
33
94
Quadlets are the modern way to run podman containers under systemd.
34
-
## 1. Install Podman
95
+
96
+
## Install Podman
97
+
35
98
```bash
36
99
apt update
37
100
apt install -y podman
38
101
```
39
-
## 2. Create Directory Structure
102
+
103
+
## Create Directory Structure
104
+
40
105
```bash
41
106
mkdir -p /etc/containers/systemd
42
107
mkdir -p /srv/tranquil-pds/{postgres,minio,valkey,certs,acme,config}
43
108
```
44
-
## 3. Create Environment File
109
+
110
+
## Create Environment File
111
+
45
112
```bash
46
113
cp /opt/tranquil-pds/.env.example /srv/tranquil-pds/config/tranquil-pds.env
47
114
chmod 600 /srv/tranquil-pds/config/tranquil-pds.env
48
115
```
116
+
49
117
Edit `/srv/tranquil-pds/config/tranquil-pds.env` and fill in your values. Generate secrets with:
50
118
```bash
51
119
openssl rand -base64 48
52
120
```
121
+
53
122
For quadlets, also add `DATABASE_URL` with the full connection string (systemd doesn't support variable expansion).
54
-
## 4. Install Quadlet Definitions
123
+
124
+
## Install Quadlet Definitions
125
+
55
126
Copy the quadlet files from the repository:
56
127
```bash
57
128
cp /opt/tranquil-pds/deploy/quadlets/*.pod /etc/containers/systemd/
58
129
cp /opt/tranquil-pds/deploy/quadlets/*.container /etc/containers/systemd/
59
130
```
131
+
60
132
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.
61
-
## 5. Create nginx Configuration
133
+
134
+
## Create nginx Configuration
135
+
62
136
```bash
63
-
cp /opt/tranquil-pds/deploy/nginx/nginx-quadlet.conf /srv/tranquil-pds/config/nginx.conf
137
+
cp /opt/tranquil-pds/nginx.frontend.conf /srv/tranquil-pds/config/nginx.conf
64
138
```
65
-
## 6. Build Tranquil PDS Image
139
+
140
+
## Clone and Build Images
141
+
66
142
```bash
67
143
cd /opt
68
-
git clone https://tangled.org/lewis.moe/bspds-sandbox tranquil-pds
144
+
git clone https://tangled.org/tranquil.farm/tranquil-pds tranquil-pds
69
145
cd tranquil-pds
70
146
podman build -t tranquil-pds:latest .
147
+
podman build -t tranquil-pds-frontend:latest ./frontend
71
148
```
72
-
## 7. Create Podman Secrets
149
+
150
+
## Create Podman Secrets
151
+
73
152
```bash
74
153
source /srv/tranquil-pds/config/tranquil-pds.env
75
154
echo "$DB_PASSWORD" | podman secret create tranquil-pds-db-password -
76
155
echo "$MINIO_ROOT_PASSWORD" | podman secret create tranquil-pds-minio-password -
77
156
```
78
-
## 8. Start Services and Initialize
157
+
158
+
## Start Services and Initialize
159
+
79
160
```bash
80
161
systemctl daemon-reload
81
162
systemctl start tranquil-pds-db tranquil-pds-minio tranquil-pds-valkey
···
87
168
podman run --rm --pod tranquil-pds \
88
169
-e MINIO_ROOT_USER=minioadmin \
89
170
-e MINIO_ROOT_PASSWORD=your-minio-password \
90
-
docker.io/minio/mc:RELEASE.2025-07-16T15-35-03Z \
171
+
cgr.dev/chainguard/minio-client:latest-dev \
91
172
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"
92
173
```
93
174
···
96
177
cargo install sqlx-cli --no-default-features --features postgres
97
178
DATABASE_URL="postgres://tranquil_pds:your-db-password@localhost:5432/pds" sqlx migrate run --source /opt/tranquil-pds/migrations
98
179
```
99
-
## 9. Obtain Wildcard SSL Certificate
100
-
User handles are served as subdomains (e.g., `alice.pds.example.com`), so you need a wildcard certificate. Wildcard certs require DNS-01 validation.
180
+
181
+
## Obtain Wildcard SSL Certificate
182
+
183
+
User handles are served as subdomains (eg., `alice.pds.example.com`), so you need a wildcard certificate. Wildcard certs require DNS-01 validation.
101
184
102
185
Create temporary self-signed cert to start services:
103
186
```bash
···
105
188
-keyout /srv/tranquil-pds/certs/privkey.pem \
106
189
-out /srv/tranquil-pds/certs/fullchain.pem \
107
190
-subj "/CN=pds.example.com"
108
-
systemctl start tranquil-pds-app tranquil-pds-nginx
191
+
systemctl start tranquil-pds-app tranquil-pds-frontend tranquil-pds-nginx
109
192
```
110
193
111
194
Get a wildcard certificate using DNS validation:
···
117
200
-d pds.example.com -d '*.pds.example.com' \
118
201
--agree-tos --email you@example.com
119
202
```
203
+
120
204
Follow the prompts to add TXT records to your DNS. Note: manual mode doesn't auto-renew.
121
205
122
-
For automated renewal, use a DNS provider plugin (e.g., cloudflare, route53).
206
+
For automated renewal, use a DNS provider plugin (eg., cloudflare, route53).
123
207
124
208
Link certificates and restart:
125
209
```bash
···
127
211
ln -sf /srv/tranquil-pds/certs/live/pds.example.com/privkey.pem /srv/tranquil-pds/certs/privkey.pem
128
212
systemctl restart tranquil-pds-nginx
129
213
```
130
-
## 10. Enable All Services
214
+
215
+
## Enable All Services
216
+
131
217
```bash
132
-
systemctl enable tranquil-pds-db tranquil-pds-minio tranquil-pds-valkey tranquil-pds-app tranquil-pds-nginx
218
+
systemctl enable tranquil-pds-db tranquil-pds-minio tranquil-pds-valkey tranquil-pds-app tranquil-pds-frontend tranquil-pds-nginx
133
219
```
134
-
## 11. Configure Firewall
220
+
221
+
## Configure Firewall
222
+
135
223
```bash
136
224
apt install -y ufw
137
225
ufw allow ssh
···
139
227
ufw allow 443/tcp
140
228
ufw enable
141
229
```
142
-
## 12. Certificate Renewal
230
+
231
+
## Certificate Renewal
232
+
143
233
Add to root's crontab (`crontab -e`):
144
234
```
145
235
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
146
236
```
237
+
147
238
---
239
+
148
240
# Alpine 3.23+ with OpenRC
241
+
149
242
Alpine uses OpenRC, not systemd. We'll use podman-compose with an OpenRC service wrapper.
150
-
## 1. Install Podman
243
+
244
+
## Install Podman
245
+
151
246
```sh
152
247
apk update
153
248
apk add podman podman-compose fuse-overlayfs cni-plugins
154
249
rc-update add cgroups
155
250
rc-service cgroups start
156
251
```
252
+
157
253
Enable podman socket for compose:
158
254
```sh
159
255
rc-update add podman
160
256
rc-service podman start
161
257
```
162
-
## 2. Create Directory Structure
258
+
259
+
## Create Directory Structure
260
+
163
261
```sh
164
262
mkdir -p /srv/tranquil-pds/{data,config}
165
263
mkdir -p /srv/tranquil-pds/data/{postgres,minio,valkey,certs,acme}
166
264
```
167
-
## 3. Clone Repository and Build
265
+
266
+
## Clone Repository and Build Images
267
+
168
268
```sh
169
269
cd /opt
170
-
git clone https://tangled.org/lewis.moe/bspds-sandbox tranquil-pds
270
+
git clone https://tangled.org/tranquil.farm/tranquil-pds tranquil-pds
171
271
cd tranquil-pds
172
272
podman build -t tranquil-pds:latest .
273
+
podman build -t tranquil-pds-frontend:latest ./frontend
173
274
```
174
-
## 4. Create Environment File
275
+
276
+
## Create Environment File
277
+
175
278
```sh
176
279
cp /opt/tranquil-pds/.env.example /srv/tranquil-pds/config/tranquil-pds.env
177
280
chmod 600 /srv/tranquil-pds/config/tranquil-pds.env
178
281
```
282
+
179
283
Edit `/srv/tranquil-pds/config/tranquil-pds.env` and fill in your values. Generate secrets with:
180
284
```sh
181
285
openssl rand -base64 48
182
286
```
183
-
## 5. Set Up Compose and nginx
287
+
288
+
## Set Up Compose and nginx
289
+
184
290
Copy the production compose and nginx configs:
185
291
```sh
186
-
cp /opt/tranquil-pds/docker-compose.prod.yml /srv/tranquil-pds/docker-compose.yml
187
-
cp /opt/tranquil-pds/nginx.prod.conf /srv/tranquil-pds/config/nginx.conf
292
+
cp /opt/tranquil-pds/docker-compose.prod.yaml /srv/tranquil-pds/docker-compose.yml
293
+
cp /opt/tranquil-pds/nginx.frontend.conf /srv/tranquil-pds/config/nginx.conf
188
294
```
295
+
189
296
Edit `/srv/tranquil-pds/docker-compose.yml` to adjust paths if needed:
190
297
- Update volume mounts to use `/srv/tranquil-pds/data/` paths
191
-
- Update nginx cert paths to match `/srv/tranquil-pds/data/certs/`
298
+
- Update nginx config path to `/srv/tranquil-pds/config/nginx.conf`
299
+
192
300
Edit `/srv/tranquil-pds/config/nginx.conf` to update cert paths:
193
301
- Change `/etc/nginx/certs/live/${PDS_HOSTNAME}/` to `/etc/nginx/certs/`
194
-
## 6. Create OpenRC Service
302
+
303
+
## Create OpenRC Service
304
+
195
305
```sh
196
306
cat > /etc/init.d/tranquil-pds << 'EOF'
197
307
#!/sbin/openrc-run
···
223
333
EOF
224
334
chmod +x /etc/init.d/tranquil-pds
225
335
```
226
-
## 7. Initialize Services
336
+
337
+
## Initialize Services
338
+
227
339
Start services:
228
340
```sh
229
341
rc-service tranquil-pds start
···
236
348
podman run --rm --network tranquil-pds_default \
237
349
-e MINIO_ROOT_USER="$MINIO_ROOT_USER" \
238
350
-e MINIO_ROOT_PASSWORD="$MINIO_ROOT_PASSWORD" \
239
-
docker.io/minio/mc:RELEASE.2025-07-16T15-35-03Z \
351
+
cgr.dev/chainguard/minio-client:latest-dev \
240
352
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'
241
353
```
242
354
···
249
361
DB_IP=$(podman inspect tranquil-pds-db-1 --format '{{.NetworkSettings.Networks.tranquil-pds_default.IPAddress}}')
250
362
DATABASE_URL="postgres://tranquil_pds:$DB_PASSWORD@$DB_IP:5432/pds" sqlx migrate run --source /opt/tranquil-pds/migrations
251
363
```
252
-
## 8. Obtain Wildcard SSL Certificate
253
-
User handles are served as subdomains (e.g., `alice.pds.example.com`), so you need a wildcard certificate. Wildcard certs require DNS-01 validation.
364
+
365
+
## Obtain Wildcard SSL Certificate
366
+
367
+
User handles are served as subdomains (eg., `alice.pds.example.com`), so you need a wildcard certificate. Wildcard certs require DNS-01 validation.
254
368
255
369
Create temporary self-signed cert to start services:
256
370
```sh
···
270
384
-d pds.example.com -d '*.pds.example.com' \
271
385
--agree-tos --email you@example.com
272
386
```
387
+
273
388
Follow the prompts to add TXT records to your DNS. Note: manual mode doesn't auto-renew.
274
389
275
390
Link certificates and restart:
···
278
393
ln -sf /srv/tranquil-pds/data/certs/live/pds.example.com/privkey.pem /srv/tranquil-pds/data/certs/privkey.pem
279
394
rc-service tranquil-pds restart
280
395
```
281
-
## 9. Enable Service at Boot
396
+
397
+
## Enable Service at Boot
398
+
282
399
```sh
283
400
rc-update add tranquil-pds
284
401
```
285
-
## 10. Configure Firewall
402
+
403
+
## Configure Firewall
404
+
286
405
```sh
287
406
apk add iptables ip6tables
288
407
iptables -A INPUT -p tcp --dport 22 -j ACCEPT
···
302
421
/etc/init.d/iptables save
303
422
/etc/init.d/ip6tables save
304
423
```
305
-
## 11. Certificate Renewal
424
+
425
+
## Certificate Renewal
426
+
306
427
Add to root's crontab (`crontab -e`):
307
428
```
308
429
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
309
430
```
431
+
310
432
---
433
+
311
434
# Verification and Maintenance
435
+
312
436
## Verify Installation
437
+
313
438
```sh
314
439
curl -s https://pds.example.com/xrpc/_health | jq
315
440
curl -s https://pds.example.com/.well-known/atproto-did
316
441
```
442
+
317
443
## View Logs
444
+
318
445
**Debian:**
319
446
```bash
320
447
journalctl -u tranquil-pds-app -f
321
448
podman logs -f tranquil-pds-app
449
+
podman logs -f tranquil-pds-frontend
322
450
```
451
+
323
452
**Alpine:**
324
453
```sh
325
454
podman-compose -f /srv/tranquil-pds/docker-compose.yml logs -f
326
455
podman logs -f tranquil-pds-tranquil-pds-1
456
+
podman logs -f tranquil-pds-frontend-1
327
457
```
458
+
328
459
## Update Tranquil PDS
460
+
329
461
```sh
330
462
cd /opt/tranquil-pds
331
463
git pull
332
464
podman build -t tranquil-pds:latest .
465
+
podman build -t tranquil-pds-frontend:latest ./frontend
333
466
```
334
467
335
468
Debian:
336
469
```bash
337
-
systemctl restart tranquil-pds-app
470
+
systemctl restart tranquil-pds-app tranquil-pds-frontend
338
471
```
339
472
340
473
Alpine:
341
474
```sh
342
475
rc-service tranquil-pds restart
343
476
```
477
+
344
478
## Backup Database
479
+
345
480
**Debian:**
346
481
```bash
347
482
podman exec tranquil-pds-db pg_dump -U tranquil_pds pds > /var/backups/pds-$(date +%Y%m%d).sql
348
483
```
484
+
349
485
**Alpine:**
350
486
```sh
351
487
podman exec tranquil-pds-db-1 pg_dump -U tranquil_pds pds > /var/backups/pds-$(date +%Y%m%d).sql
···
353
489
354
490
## Custom Homepage
355
491
356
-
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.
492
+
The frontend container serves `homepage.html` as the landing page. To customize it, either:
493
+
494
+
1. Build a custom frontend image with your own `homepage.html`
495
+
2. Mount a custom `homepage.html` into the frontend container
357
496
497
+
Example custom homepage:
358
498
```html
359
499
<!DOCTYPE html>
360
500
<html>
+173
-26
docs/install-debian.md
+173
-26
docs/install-debian.md
···
1
1
# Tranquil PDS Production Installation on Debian
2
+
2
3
This guide covers installing Tranquil PDS on Debian 13.
3
4
4
5
## Prerequisites
6
+
5
7
- A VPS with at least 2GB RAM and 20GB disk
6
8
- A domain name pointing to your server's IP
7
9
- A wildcard TLS certificate for `*.pds.example.com` (user handles are served as subdomains)
8
10
- Root or sudo access
9
-
## 1. System Setup
11
+
12
+
## System Setup
13
+
10
14
```bash
11
15
apt update && apt upgrade -y
12
16
apt install -y curl git build-essential pkg-config libssl-dev
13
17
```
14
-
## 2. Install Rust
18
+
19
+
## Install Rust
20
+
15
21
```bash
16
22
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
17
23
source ~/.cargo/env
18
24
rustup default stable
19
25
```
26
+
20
27
This installs the latest stable Rust.
21
-
## 3. Install postgres
28
+
29
+
## Install postgres
30
+
22
31
```bash
23
32
apt install -y postgresql postgresql-contrib
24
33
systemctl enable postgresql
···
27
36
sudo -u postgres psql -c "CREATE DATABASE pds OWNER tranquil_pds;"
28
37
sudo -u postgres psql -c "GRANT ALL PRIVILEGES ON DATABASE pds TO tranquil_pds;"
29
38
```
30
-
## 4. Install minio
39
+
40
+
## Install minio
41
+
31
42
```bash
32
43
curl -O https://dl.min.io/server/minio/release/linux-amd64/minio
33
44
chmod +x minio
···
59
70
systemctl enable minio
60
71
systemctl start minio
61
72
```
73
+
62
74
Create the buckets (wait a few seconds for minio to start):
63
75
```bash
64
76
curl -O https://dl.min.io/client/mc/release/linux-amd64/mc
···
68
80
mc mb local/pds-blobs
69
81
mc mb local/pds-backups
70
82
```
71
-
## 5. Install valkey
83
+
84
+
## Install valkey
85
+
72
86
```bash
73
87
apt install -y valkey
74
88
systemctl enable valkey-server
75
89
systemctl start valkey-server
76
90
```
77
-
## 6. Install deno (for frontend build)
91
+
92
+
## Install deno (for frontend build)
93
+
78
94
```bash
79
95
curl -fsSL https://deno.land/install.sh | sh
80
96
export PATH="$HOME/.deno/bin:$PATH"
81
97
echo 'export PATH="$HOME/.deno/bin:$PATH"' >> ~/.bashrc
82
98
```
83
-
## 7. Clone and Build Tranquil PDS
99
+
100
+
## Clone and Build Tranquil PDS
101
+
84
102
```bash
85
103
cd /opt
86
-
git clone https://tangled.org/lewis.moe/bspds-sandbox tranquil-pds
104
+
git clone https://tangled.org/tranquil.farm/tranquil-pds tranquil-pds
87
105
cd tranquil-pds
88
106
cd frontend
89
107
deno task build
90
108
cd ..
91
109
cargo build --release
92
110
```
93
-
## 8. Install sqlx-cli and Run Migrations
111
+
112
+
## Install sqlx-cli and Run Migrations
113
+
94
114
```bash
95
115
cargo install sqlx-cli --no-default-features --features postgres
96
116
export DATABASE_URL="postgres://tranquil_pds:your-secure-password@localhost:5432/pds"
97
117
sqlx migrate run
98
118
```
99
-
## 9. Configure Tranquil PDS
119
+
120
+
## Configure Tranquil PDS
121
+
100
122
```bash
101
123
mkdir -p /etc/tranquil-pds
102
124
cp /opt/tranquil-pds/.env.example /etc/tranquil-pds/tranquil-pds.env
103
125
chmod 600 /etc/tranquil-pds/tranquil-pds.env
104
126
```
127
+
105
128
Edit `/etc/tranquil-pds/tranquil-pds.env` and fill in your values. Generate secrets with:
106
129
```bash
107
130
openssl rand -base64 48
108
131
```
109
-
## 10. Create Systemd Service
132
+
133
+
## Install Frontend Files
134
+
135
+
```bash
136
+
mkdir -p /var/www/tranquil-pds
137
+
cp -r /opt/tranquil-pds/frontend/dist/* /var/www/tranquil-pds/
138
+
chown -R www-data:www-data /var/www/tranquil-pds
139
+
```
140
+
141
+
## Create Systemd Service
142
+
110
143
```bash
111
144
useradd -r -s /sbin/nologin tranquil-pds
112
145
cp /opt/tranquil-pds/target/release/tranquil-pds /usr/local/bin/
113
-
mkdir -p /var/lib/tranquil-pds
114
-
cp -r /opt/tranquil-pds/frontend/dist /var/lib/tranquil-pds/frontend
115
-
chown -R tranquil-pds:tranquil-pds /var/lib/tranquil-pds
146
+
116
147
cat > /etc/systemd/system/tranquil-pds.service << 'EOF'
117
148
[Unit]
118
149
Description=Tranquil PDS - AT Protocol PDS
···
122
153
User=tranquil-pds
123
154
Group=tranquil-pds
124
155
EnvironmentFile=/etc/tranquil-pds/tranquil-pds.env
125
-
Environment=FRONTEND_DIR=/var/lib/tranquil-pds/frontend
126
156
ExecStart=/usr/local/bin/tranquil-pds
127
157
Restart=always
128
158
RestartSec=5
129
159
[Install]
130
160
WantedBy=multi-user.target
131
161
EOF
162
+
132
163
systemctl daemon-reload
133
164
systemctl enable tranquil-pds
134
165
systemctl start tranquil-pds
135
166
```
136
-
## 11. Install and Configure nginx
167
+
168
+
## Install and Configure nginx
169
+
137
170
```bash
138
171
apt install -y nginx certbot python3-certbot-nginx
172
+
139
173
cat > /etc/nginx/sites-available/tranquil-pds << 'EOF'
140
174
server {
141
175
listen 80;
142
176
listen [::]:80;
143
-
server_name pds.example.com;
177
+
server_name pds.example.com *.pds.example.com;
178
+
179
+
location /.well-known/acme-challenge/ {
180
+
root /var/www/acme;
181
+
}
182
+
144
183
location / {
184
+
return 301 https://$host$request_uri;
185
+
}
186
+
}
187
+
188
+
server {
189
+
listen 443 ssl;
190
+
listen [::]:443 ssl;
191
+
http2 on;
192
+
server_name pds.example.com *.pds.example.com;
193
+
194
+
ssl_certificate /etc/letsencrypt/live/pds.example.com/fullchain.pem;
195
+
ssl_certificate_key /etc/letsencrypt/live/pds.example.com/privkey.pem;
196
+
197
+
client_max_body_size 10G;
198
+
199
+
root /var/www/tranquil-pds;
200
+
201
+
location /xrpc/ {
145
202
proxy_pass http://127.0.0.1:3000;
146
203
proxy_http_version 1.1;
147
204
proxy_set_header Upgrade $http_upgrade;
···
151
208
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
152
209
proxy_set_header X-Forwarded-Proto $scheme;
153
210
proxy_read_timeout 86400;
211
+
proxy_send_timeout 86400;
212
+
proxy_buffering off;
213
+
proxy_request_buffering off;
214
+
}
215
+
216
+
location /oauth/ {
217
+
proxy_pass http://127.0.0.1:3000;
218
+
proxy_http_version 1.1;
219
+
proxy_set_header Host $host;
220
+
proxy_set_header X-Real-IP $remote_addr;
221
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
222
+
proxy_set_header X-Forwarded-Proto $scheme;
223
+
proxy_read_timeout 300;
224
+
proxy_send_timeout 300;
225
+
}
226
+
227
+
location /.well-known/ {
228
+
proxy_pass http://127.0.0.1:3000;
229
+
proxy_http_version 1.1;
230
+
proxy_set_header Host $host;
231
+
proxy_set_header X-Real-IP $remote_addr;
232
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
233
+
proxy_set_header X-Forwarded-Proto $scheme;
234
+
}
235
+
236
+
location = /metrics {
237
+
proxy_pass http://127.0.0.1:3000;
238
+
proxy_http_version 1.1;
239
+
proxy_set_header Host $host;
240
+
}
241
+
242
+
location = /health {
243
+
proxy_pass http://127.0.0.1:3000;
244
+
proxy_http_version 1.1;
245
+
proxy_set_header Host $host;
246
+
}
247
+
248
+
location = /robots.txt {
249
+
proxy_pass http://127.0.0.1:3000;
250
+
proxy_http_version 1.1;
251
+
proxy_set_header Host $host;
252
+
}
253
+
254
+
location = /logo {
255
+
proxy_pass http://127.0.0.1:3000;
256
+
proxy_http_version 1.1;
257
+
proxy_set_header Host $host;
258
+
}
259
+
260
+
location ~ ^/u/[^/]+/did\.json$ {
261
+
proxy_pass http://127.0.0.1:3000;
262
+
proxy_http_version 1.1;
263
+
proxy_set_header Host $host;
264
+
proxy_set_header X-Real-IP $remote_addr;
265
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
266
+
proxy_set_header X-Forwarded-Proto $scheme;
267
+
}
268
+
269
+
location /assets/ {
270
+
expires 1y;
271
+
add_header Cache-Control "public, immutable";
272
+
try_files $uri =404;
273
+
}
274
+
275
+
location /app/ {
276
+
try_files $uri $uri/ /index.html;
277
+
}
278
+
279
+
location = / {
280
+
try_files /homepage.html /index.html;
281
+
}
282
+
283
+
location / {
284
+
try_files $uri $uri/ /index.html;
154
285
}
155
286
}
156
287
EOF
157
-
ln -s /etc/nginx/sites-available/tranquil-pds /etc/nginx/sites-enabled/
288
+
289
+
ln -sf /etc/nginx/sites-available/tranquil-pds /etc/nginx/sites-enabled/
158
290
rm -f /etc/nginx/sites-enabled/default
291
+
mkdir -p /var/www/acme
159
292
nginx -t
160
293
systemctl reload nginx
161
294
```
162
-
## 12. Obtain Wildcard SSL Certificate
163
-
User handles are served as subdomains (e.g., `alice.pds.example.com`), so you need a wildcard certificate.
295
+
296
+
## Obtain Wildcard SSL Certificate
297
+
298
+
User handles are served as subdomains (eg., `alice.pds.example.com`), so you need a wildcard certificate.
164
299
165
300
Wildcard certs require DNS-01 validation. If your DNS provider has a certbot plugin:
166
301
```bash
···
175
310
certbot certonly --manual --preferred-challenges dns \
176
311
-d pds.example.com -d '*.pds.example.com'
177
312
```
313
+
178
314
Follow the prompts to add TXT records to your DNS. Note: manual mode doesn't auto-renew.
179
315
180
-
After obtaining the cert, update nginx to use it and reload.
181
-
## 13. Configure Firewall
316
+
After obtaining the cert, reload nginx:
317
+
```bash
318
+
systemctl reload nginx
319
+
```
320
+
321
+
## Configure Firewall
322
+
182
323
```bash
183
324
apt install -y ufw
184
325
ufw allow ssh
···
186
327
ufw allow 443/tcp
187
328
ufw enable
188
329
```
189
-
## 14. Verify Installation
330
+
331
+
## Verify Installation
332
+
190
333
```bash
191
334
systemctl status tranquil-pds
192
335
curl -s https://pds.example.com/xrpc/_health | jq
193
336
curl -s https://pds.example.com/.well-known/atproto-did
194
337
```
338
+
195
339
## Maintenance
340
+
196
341
View logs:
197
342
```bash
198
343
journalctl -u tranquil-pds -f
199
344
```
345
+
200
346
Update Tranquil PDS:
201
347
```bash
202
348
cd /opt/tranquil-pds
···
205
351
cargo build --release
206
352
systemctl stop tranquil-pds
207
353
cp target/release/tranquil-pds /usr/local/bin/
208
-
cp -r frontend/dist /var/lib/tranquil-pds/frontend
354
+
cp -r frontend/dist/* /var/www/tranquil-pds/
209
355
DATABASE_URL="postgres://tranquil_pds:your-secure-password@localhost:5432/pds" sqlx migrate run
210
356
systemctl start tranquil-pds
211
357
```
358
+
212
359
Backup database:
213
360
```bash
214
361
sudo -u postgres pg_dump pds > /var/backups/pds-$(date +%Y%m%d).sql
···
216
363
217
364
## Custom Homepage
218
365
219
-
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.
366
+
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.
220
367
221
368
```bash
222
-
cat > /var/lib/tranquil-pds/frontend/homepage.html << 'EOF'
369
+
cat > /var/www/tranquil-pds/homepage.html << 'EOF'
223
370
<!DOCTYPE html>
224
371
<html>
225
372
<head>
+9
frontend/Dockerfile
+9
frontend/Dockerfile
+38
frontend/nginx-quadlet.conf
+38
frontend/nginx-quadlet.conf
···
1
+
server {
2
+
listen 8080;
3
+
listen [::]:8080;
4
+
server_name _;
5
+
6
+
root /usr/share/nginx/html;
7
+
index index.html;
8
+
9
+
gzip on;
10
+
gzip_vary on;
11
+
gzip_types text/plain text/css application/json application/javascript text/xml application/xml;
12
+
13
+
location = /oauth/client-metadata.json {
14
+
default_type application/json;
15
+
sub_filter_once off;
16
+
sub_filter_types application/json;
17
+
sub_filter '__PDS_HOSTNAME__' $host;
18
+
try_files /oauth/client-metadata.json =404;
19
+
}
20
+
21
+
location /assets/ {
22
+
expires 1y;
23
+
add_header Cache-Control "public, immutable";
24
+
try_files $uri =404;
25
+
}
26
+
27
+
location = / {
28
+
try_files /homepage.html /index.html;
29
+
}
30
+
31
+
location /app/ {
32
+
try_files $uri $uri/ /index.html;
33
+
}
34
+
35
+
location / {
36
+
try_files $uri $uri/ /index.html;
37
+
}
38
+
}
+38
frontend/nginx.conf
+38
frontend/nginx.conf
···
1
+
server {
2
+
listen 80;
3
+
listen [::]:80;
4
+
server_name _;
5
+
6
+
root /usr/share/nginx/html;
7
+
index index.html;
8
+
9
+
gzip on;
10
+
gzip_vary on;
11
+
gzip_types text/plain text/css application/json application/javascript text/xml application/xml;
12
+
13
+
location = /oauth/client-metadata.json {
14
+
default_type application/json;
15
+
sub_filter_once off;
16
+
sub_filter_types application/json;
17
+
sub_filter '__PDS_HOSTNAME__' $host;
18
+
try_files /oauth/client-metadata.json =404;
19
+
}
20
+
21
+
location /assets/ {
22
+
expires 1y;
23
+
add_header Cache-Control "public, immutable";
24
+
try_files $uri =404;
25
+
}
26
+
27
+
location = / {
28
+
try_files /homepage.html /index.html;
29
+
}
30
+
31
+
location /app/ {
32
+
try_files $uri $uri/ /index.html;
33
+
}
34
+
35
+
location / {
36
+
try_files $uri $uri/ /index.html;
37
+
}
38
+
}
+5
-5
frontend/public/homepage.html
+5
-5
frontend/public/homepage.html
···
440
440
<a href="/app/register" class="btn primary" id="heroPrimary"
441
441
>Join This Server</a>
442
442
<a
443
-
href="https://tangled.org/lewis.moe/bspds-sandbox"
443
+
href="https://tangled.org/tranquil.farm/tranquil-pds"
444
444
class="btn secondary"
445
445
id="heroSecondary"
446
446
target="_blank"
···
461
461
<div class="feature">
462
462
<h3>Real security</h3>
463
463
<p>
464
-
Sign in with passkeys, add two-factor authentication, set up
465
-
backup codes, and mark devices you trust. Your account stays
466
-
yours.
464
+
Sign in with passkeys or SSO, add two-factor authentication,
465
+
set up backup codes, and mark devices you trust. Your account
466
+
stays yours.
467
467
</p>
468
468
</div>
469
469
···
546
546
<a href="/app/register" class="btn primary" id="footerPrimary"
547
547
>Join This Server</a>
548
548
<a
549
-
href="https://tangled.org/lewis.moe/bspds-sandbox"
549
+
href="https://tangled.org/tranquil.farm/tranquil-pds"
550
550
class="btn secondary"
551
551
target="_blank"
552
552
rel="noopener"
+15
frontend/public/oauth/client-metadata.json
+15
frontend/public/oauth/client-metadata.json
···
1
+
{
2
+
"client_id": "https://__PDS_HOSTNAME__/oauth/client-metadata.json",
3
+
"client_name": "PDS Account Manager",
4
+
"client_uri": "https://__PDS_HOSTNAME__",
5
+
"redirect_uris": [
6
+
"https://__PDS_HOSTNAME__/app/",
7
+
"https://__PDS_HOSTNAME__/app/migrate"
8
+
],
9
+
"grant_types": ["authorization_code", "refresh_token"],
10
+
"response_types": ["code"],
11
+
"scope": "atproto transition:generic repo:* blob:*/* rpc:* rpc:com.atproto.server.createAccount?aud=* account:*?action=manage identity:*",
12
+
"token_endpoint_auth_method": "none",
13
+
"application_type": "web",
14
+
"dpop_bound_access_tokens": true
15
+
}
+8
-8
frontend/src/locales/en.json
+8
-8
frontend/src/locales/en.json
···
160
160
"signal": "Signal",
161
161
"signalNumber": "Signal Phone Number",
162
162
"signalNumberPlaceholder": "+1234567890",
163
-
"signalNumberHint": "Include country code (e.g., +1 for US)",
163
+
"signalNumberHint": "Include country code (eg., +1 for US)",
164
164
"notConfigured": "not configured",
165
165
"inviteCode": "Invite Code",
166
166
"inviteCodePlaceholder": "Enter your invite code",
···
263
263
"saveFailed": "Failed to save DID document",
264
264
"loadFailed": "Failed to load DID document",
265
265
"invalidMultibase": "Public key must be a valid multibase string starting with 'z'",
266
-
"invalidHandle": "Handle must be an at:// URI (e.g., at://handle.example.com)",
266
+
"invalidHandle": "Handle must be an at:// URI (eg., at://handle.example.com)",
267
267
"helpTitle": "What is this?",
268
268
"helpText": "When you migrate to another PDS, that PDS generates new signing keys. Update your DID document here so it points to your new keys and location. This enables multi-hop migrations (PDS 1 โ PDS 2 โ PDS 3)."
269
269
},
···
385
385
"title": "App Passwords",
386
386
"description": "App passwords let you sign in to third-party apps without giving them your main password. Each app password can be revoked individually.",
387
387
"createNew": "Create New App Password",
388
-
"appNamePlaceholder": "App name (e.g., Graysky, Skeets)",
388
+
"appNamePlaceholder": "App name (eg., Graysky, Skeets)",
389
389
"created": "App Password Created",
390
390
"createdMessage": "Copy this password now. You won't be able to see it again.",
391
391
"yourPasswords": "Your App Passwords",
···
452
452
"adding": "Adding...",
453
453
"noPasskeys": "No passkeys registered",
454
454
"passkeyName": "Passkey name",
455
-
"passkeyNamePlaceholder": "e.g., MacBook Pro, iPhone",
455
+
"passkeyNamePlaceholder": "eg., MacBook Pro, iPhone",
456
456
"register": "Register",
457
457
"registering": "Registering...",
458
458
"rename": "Rename",
···
1007
1007
"infoAppAccess": "Using third-party apps",
1008
1008
"infoAppAccessDesc": "After creating your account, you will receive an app password. Use this to sign in to Bluesky apps and other AT Protocol clients.",
1009
1009
"passkeyNameLabel": "Passkey Name (optional)",
1010
-
"passkeyNamePlaceholder": "e.g., MacBook Touch ID",
1010
+
"passkeyNamePlaceholder": "eg., MacBook Touch ID",
1011
1011
"passkeyNameHint": "A friendly name to identify this passkey",
1012
1012
"passkeyPrompt": "Click the button below to create your passkey. You'll be prompted to use:",
1013
1013
"passkeyPromptBullet1": "Touch ID or Face ID",
···
1279
1279
"checkingAvailability": "Checking availability...",
1280
1280
"handleAvailable": "Handle is available!",
1281
1281
"handleTaken": "Handle is already taken",
1282
-
"handleHint": "You can also use your own domain by entering the full handle (e.g., alice.mydomain.com)",
1282
+
"handleHint": "You can also use your own domain by entering the full handle (eg., alice.mydomain.com)",
1283
1283
"email": "Email Address",
1284
1284
"authMethod": "Authentication Method",
1285
1285
"authPassword": "Password",
···
1320
1320
"title": "Set Up Your Passkey",
1321
1321
"desc": "Your email has been verified. Now set up your passkey for secure, passwordless login.",
1322
1322
"nameLabel": "Passkey Name (optional)",
1323
-
"namePlaceholder": "e.g., MacBook Pro, iPhone",
1323
+
"namePlaceholder": "eg., MacBook Pro, iPhone",
1324
1324
"nameHint": "A friendly name to identify this passkey",
1325
1325
"instructions": "Click the button below to register your passkey. Your device will prompt you to use biometrics (fingerprint, Face ID) or a security key.",
1326
1326
"register": "Register Passkey",
···
1418
1418
"title": "Enter Your DID",
1419
1419
"desc": "Enter the DID of the account you want to restore.",
1420
1420
"label": "Your DID",
1421
-
"hint": "Your decentralized identifier (e.g., did:plc:abc123...)"
1421
+
"hint": "Your decentralized identifier (eg., did:plc:abc123...)"
1422
1422
},
1423
1423
"uploadCar": {
1424
1424
"title": "Upload Repository Backup",
+4
-3
justfile
+4
-3
justfile
···
23
23
SQLX_OFFLINE=true cargo test --test dpop_unit --test validation_edge_cases --test scope_edge_cases
24
24
25
25
test-auth:
26
-
./scripts/run-tests.sh --test oauth --test oauth_lifecycle --test oauth_scopes --test oauth_security --test oauth_client_metadata --test jwt_security --test session_management --test change_password --test password_reset
26
+
./scripts/run-tests.sh --test oauth --test oauth_lifecycle --test oauth_scopes --test oauth_security --test jwt_security --test session_management --test change_password --test password_reset
27
27
28
28
test-admin:
29
29
./scripts/run-tests.sh --test admin_email --test admin_invite --test admin_moderation --test admin_search --test admin_stats
···
81
81
podman compose down
82
82
podman-logs:
83
83
podman compose logs -f
84
-
podman-build:
85
-
podman compose build
84
+
container-build:
85
+
podman build -t tranquil-pds:latest .
86
+
podman build -t tranquil-pds-frontend:latest ./frontend
86
87
87
88
frontend-dev:
88
89
. ~/.deno/env && cd frontend && deno task dev
+170
nginx.frontend.conf
+170
nginx.frontend.conf
···
1
+
worker_processes auto;
2
+
error_log /var/log/nginx/error.log warn;
3
+
pid /var/run/nginx.pid;
4
+
5
+
events {
6
+
worker_connections 4096;
7
+
use epoll;
8
+
multi_accept on;
9
+
}
10
+
11
+
http {
12
+
include /etc/nginx/mime.types;
13
+
default_type application/octet-stream;
14
+
15
+
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
16
+
'$status $body_bytes_sent "$http_referer" '
17
+
'"$http_user_agent" "$http_x_forwarded_for" '
18
+
'rt=$request_time uct="$upstream_connect_time" '
19
+
'uht="$upstream_header_time" urt="$upstream_response_time"';
20
+
21
+
access_log /var/log/nginx/access.log main;
22
+
23
+
sendfile on;
24
+
tcp_nopush on;
25
+
tcp_nodelay on;
26
+
keepalive_timeout 65;
27
+
types_hash_max_size 2048;
28
+
29
+
gzip on;
30
+
gzip_vary on;
31
+
gzip_proxied any;
32
+
gzip_comp_level 6;
33
+
gzip_types text/plain text/css text/xml application/json application/javascript
34
+
application/xml application/xml+rss text/javascript application/activity+json;
35
+
36
+
ssl_protocols TLSv1.2 TLSv1.3;
37
+
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
38
+
ssl_prefer_server_ciphers off;
39
+
ssl_session_cache shared:SSL:10m;
40
+
ssl_session_timeout 1d;
41
+
ssl_session_tickets off;
42
+
ssl_stapling on;
43
+
ssl_stapling_verify on;
44
+
45
+
upstream backend {
46
+
server tranquil-pds:3000;
47
+
keepalive 32;
48
+
}
49
+
50
+
upstream frontend {
51
+
server frontend:80;
52
+
keepalive 16;
53
+
}
54
+
55
+
server {
56
+
listen 80;
57
+
listen [::]:80;
58
+
server_name _;
59
+
60
+
location /.well-known/acme-challenge/ {
61
+
root /var/www/acme;
62
+
}
63
+
64
+
location / {
65
+
return 301 https://$host$request_uri;
66
+
}
67
+
}
68
+
69
+
server {
70
+
listen 443 ssl;
71
+
listen [::]:443 ssl;
72
+
http2 on;
73
+
server_name _;
74
+
75
+
ssl_certificate /etc/nginx/certs/fullchain.pem;
76
+
ssl_certificate_key /etc/nginx/certs/privkey.pem;
77
+
78
+
client_max_body_size 10G;
79
+
80
+
location /xrpc/ {
81
+
proxy_pass http://backend;
82
+
proxy_http_version 1.1;
83
+
proxy_set_header Upgrade $http_upgrade;
84
+
proxy_set_header Connection "upgrade";
85
+
proxy_set_header Host $host;
86
+
proxy_set_header X-Real-IP $remote_addr;
87
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
88
+
proxy_set_header X-Forwarded-Proto $scheme;
89
+
proxy_read_timeout 86400;
90
+
proxy_send_timeout 86400;
91
+
proxy_buffering off;
92
+
proxy_request_buffering off;
93
+
}
94
+
95
+
location = /oauth/client-metadata.json {
96
+
proxy_pass http://frontend;
97
+
proxy_http_version 1.1;
98
+
proxy_set_header Host $host;
99
+
proxy_set_header Accept-Encoding "";
100
+
sub_filter_once off;
101
+
sub_filter_types application/json;
102
+
sub_filter '__PDS_HOSTNAME__' $host;
103
+
}
104
+
105
+
location /oauth/ {
106
+
proxy_pass http://backend;
107
+
proxy_http_version 1.1;
108
+
proxy_set_header Host $host;
109
+
proxy_set_header X-Real-IP $remote_addr;
110
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
111
+
proxy_set_header X-Forwarded-Proto $scheme;
112
+
proxy_read_timeout 300;
113
+
proxy_send_timeout 300;
114
+
}
115
+
116
+
location /.well-known/ {
117
+
proxy_pass http://backend;
118
+
proxy_http_version 1.1;
119
+
proxy_set_header Host $host;
120
+
proxy_set_header X-Real-IP $remote_addr;
121
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
122
+
proxy_set_header X-Forwarded-Proto $scheme;
123
+
}
124
+
125
+
location = /metrics {
126
+
proxy_pass http://backend;
127
+
proxy_http_version 1.1;
128
+
proxy_set_header Host $host;
129
+
proxy_set_header X-Real-IP $remote_addr;
130
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
131
+
proxy_set_header X-Forwarded-Proto $scheme;
132
+
}
133
+
134
+
location = /health {
135
+
proxy_pass http://backend;
136
+
proxy_http_version 1.1;
137
+
proxy_set_header Host $host;
138
+
}
139
+
140
+
location = /robots.txt {
141
+
proxy_pass http://backend;
142
+
proxy_http_version 1.1;
143
+
proxy_set_header Host $host;
144
+
}
145
+
146
+
location = /logo {
147
+
proxy_pass http://backend;
148
+
proxy_http_version 1.1;
149
+
proxy_set_header Host $host;
150
+
}
151
+
152
+
location ~ ^/u/[^/]+/did\.json$ {
153
+
proxy_pass http://backend;
154
+
proxy_http_version 1.1;
155
+
proxy_set_header Host $host;
156
+
proxy_set_header X-Real-IP $remote_addr;
157
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
158
+
proxy_set_header X-Forwarded-Proto $scheme;
159
+
}
160
+
161
+
location / {
162
+
proxy_pass http://frontend;
163
+
proxy_http_version 1.1;
164
+
proxy_set_header Host $host;
165
+
proxy_set_header X-Real-IP $remote_addr;
166
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
167
+
proxy_set_header X-Forwarded-Proto $scheme;
168
+
}
169
+
}
170
+
}
-87
nginx.prod.conf
-87
nginx.prod.conf
···
1
-
worker_processes auto;
2
-
error_log /var/log/nginx/error.log warn;
3
-
pid /var/run/nginx.pid;
4
-
events {
5
-
worker_connections 4096;
6
-
use epoll;
7
-
multi_accept on;
8
-
}
9
-
http {
10
-
include /etc/nginx/mime.types;
11
-
default_type application/octet-stream;
12
-
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
13
-
'$status $body_bytes_sent "$http_referer" '
14
-
'"$http_user_agent" "$http_x_forwarded_for" '
15
-
'rt=$request_time uct="$upstream_connect_time" '
16
-
'uht="$upstream_header_time" urt="$upstream_response_time"';
17
-
access_log /var/log/nginx/access.log main;
18
-
sendfile on;
19
-
tcp_nopush on;
20
-
tcp_nodelay on;
21
-
keepalive_timeout 65;
22
-
types_hash_max_size 2048;
23
-
gzip on;
24
-
gzip_vary on;
25
-
gzip_proxied any;
26
-
gzip_comp_level 6;
27
-
gzip_types text/plain text/css text/xml application/json application/javascript
28
-
application/xml application/xml+rss text/javascript application/activity+json;
29
-
ssl_protocols TLSv1.2 TLSv1.3;
30
-
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
31
-
ssl_prefer_server_ciphers off;
32
-
ssl_session_cache shared:SSL:10m;
33
-
ssl_session_timeout 1d;
34
-
ssl_session_tickets off;
35
-
ssl_stapling on;
36
-
ssl_stapling_verify on;
37
-
upstream tranquil-pds {
38
-
server tranquil-pds:3000;
39
-
keepalive 32;
40
-
}
41
-
server {
42
-
listen 80;
43
-
listen [::]:80;
44
-
server_name _;
45
-
location /.well-known/acme-challenge/ {
46
-
root /var/www/acme;
47
-
}
48
-
location / {
49
-
return 301 https://$host$request_uri;
50
-
}
51
-
}
52
-
server {
53
-
listen 443 ssl http2;
54
-
listen [::]:443 ssl http2;
55
-
server_name _;
56
-
ssl_certificate /etc/nginx/certs/live/${PDS_HOSTNAME}/fullchain.pem;
57
-
ssl_certificate_key /etc/nginx/certs/live/${PDS_HOSTNAME}/privkey.pem;
58
-
client_max_body_size 10G;
59
-
location / {
60
-
proxy_pass http://tranquil-pds;
61
-
proxy_http_version 1.1;
62
-
proxy_set_header Upgrade $http_upgrade;
63
-
proxy_set_header Connection "upgrade";
64
-
proxy_set_header Host $host;
65
-
proxy_set_header X-Real-IP $remote_addr;
66
-
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
67
-
proxy_set_header X-Forwarded-Proto $scheme;
68
-
proxy_read_timeout 86400;
69
-
proxy_send_timeout 86400;
70
-
proxy_buffering off;
71
-
proxy_request_buffering off;
72
-
}
73
-
location /xrpc/com.atproto.sync.subscribeRepos {
74
-
proxy_pass http://tranquil-pds;
75
-
proxy_http_version 1.1;
76
-
proxy_set_header Upgrade $http_upgrade;
77
-
proxy_set_header Connection "upgrade";
78
-
proxy_set_header Host $host;
79
-
proxy_set_header X-Real-IP $remote_addr;
80
-
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
81
-
proxy_set_header X-Forwarded-Proto $scheme;
82
-
proxy_read_timeout 86400;
83
-
proxy_send_timeout 86400;
84
-
proxy_buffering off;
85
-
}
86
-
}
87
-
}
+1
-1
observability/prometheus.yml
observability/prometheus.yaml
+1
-1
observability/prometheus.yml
observability/prometheus.yaml
+3
-4
scripts/install-debian.sh
+3
-4
scripts/install-debian.sh
···
117
117
[[ -n "$IPV6" ]] && echo " IPv6: ${IPV6}"
118
118
echo ""
119
119
120
-
read -p "Enter your PDS domain (e.g., pds.example.com): " PDS_DOMAIN
120
+
read -p "Enter your PDS domain (eg., pds.example.com): " PDS_DOMAIN
121
121
if [[ -z "$PDS_DOMAIN" ]]; then
122
122
log_error "Domain cannot be empty"
123
123
exit 1
···
296
296
297
297
log_info "Cloning Tranquil PDS..."
298
298
if [[ ! -d /opt/tranquil-pds ]]; then
299
-
git clone https://tangled.org/lewis.moe/bspds-sandbox /opt/tranquil-pds
299
+
git clone https://tangled.org/tranquil.farm/tranquil-pds /opt/tranquil-pds
300
300
else
301
301
cd /opt/tranquil-pds && git pull
302
302
fi
···
417
417
User=tranquil-pds
418
418
Group=tranquil-pds
419
419
EnvironmentFile=/etc/tranquil-pds/tranquil-pds.env
420
-
Environment=FRONTEND_DIR=/var/lib/tranquil-pds/frontend
421
420
ExecStart=/usr/local/bin/tranquil-pds
422
421
Restart=always
423
422
RestartSec=5
···
479
478
echo ""
480
479
log_info "Obtaining wildcard SSL certificate..."
481
480
echo ""
482
-
echo "User handles are served as subdomains (e.g., alice.${PDS_DOMAIN}),"
481
+
echo "User handles are served as subdomains (eg., alice.${PDS_DOMAIN}),"
483
482
echo "so you need a wildcard certificate. This requires DNS validation."
484
483
echo ""
485
484
echo "You'll need to add a TXT record to your DNS when prompted."
+5
-5
scripts/test-infra.sh
+5
-5
scripts/test-infra.sh
···
48
48
--name "${CONTAINER_PREFIX}-minio" \
49
49
-e MINIO_ROOT_USER=minioadmin \
50
50
-e MINIO_ROOT_PASSWORD=minioadmin \
51
-
-P \
51
+
-p 9000 \
52
52
--label tranquil_pds_test=true \
53
-
minio/minio:latest server /data >/dev/null
53
+
cgr.dev/chainguard/minio:latest server /data >/dev/null
54
54
echo "Starting Valkey..."
55
55
$CONTAINER_CMD run -d \
56
56
--name "${CONTAINER_PREFIX}-valkey" \
57
57
-P \
58
58
--label tranquil_pds_test=true \
59
-
valkey/valkey:8-alpine >/dev/null
59
+
valkey/valkey:9-alpine >/dev/null
60
60
echo "Waiting for services to be ready..."
61
61
sleep 2
62
62
PG_PORT=$($CONTAINER_CMD port "${CONTAINER_PREFIX}-postgres" 5432 | head -1 | cut -d: -f2)
···
86
86
echo "Creating MinIO buckets..."
87
87
$CONTAINER_CMD run --rm --network host \
88
88
-e MC_HOST_minio="http://minioadmin:minioadmin@127.0.0.1:${MINIO_PORT}" \
89
-
minio/mc:latest mb minio/test-bucket --ignore-existing >/dev/null 2>&1 || true
89
+
cgr.dev/chainguard/minio-client:latest-dev mb minio/test-bucket --ignore-existing >/dev/null 2>&1 || true
90
90
$CONTAINER_CMD run --rm --network host \
91
91
-e MC_HOST_minio="http://minioadmin:minioadmin@127.0.0.1:${MINIO_PORT}" \
92
-
minio/mc:latest mb minio/test-backups --ignore-existing >/dev/null 2>&1 || true
92
+
cgr.dev/chainguard/minio-client:latest-dev mb minio/test-backups --ignore-existing >/dev/null 2>&1 || true
93
93
cat > "$INFRA_FILE" << EOF
94
94
export DATABASE_URL="postgres://postgres:postgres@127.0.0.1:${PG_PORT}/postgres"
95
95
export TEST_DB_PORT="${PG_PORT}"
History
2 rounds
2 comments
expand 1 comment
pull request successfully merged
expand 1 comment
would it be possible to seperate out the frontends oauth client metadata from the pds too? using something like nginx sub_filter to replace out the frontends hostname? id like to have the pds completely not care about the frontend (beyond providing the apis it needs) if possible
otherwise looks great!
looks good! im happy