Skip to content

Commit 03d4c7d

Browse files
pashagolubdf7cbdenys-holub
authored
[+] add support for reverse proxies on a different path, closes #1049 (#1063)
* add support for reverse proxies on a different path, closes #1049 Users can now configure pgwatch to run under a custom path (e.g., http::/example.com/pgwatch) when behind a reverse proxy like Apache or Nginx. - add `--web-base-path` flag to specify custom path prefix - all API endpoints and static assets respect the configured base path - inject `<base>` tag and React Router `basename` dynamically at runtime - set `homepage: "."` in `package.json` for relative asset paths Example usage: pgwatch --web-base-path=pgwatch --web-addr=:8080 Then configure reverse proxy to forward `/pgwatch/*` to `localhost:8080/pgwatch/*` * Put the WebSocket config into the example config The WebSocket proxy needs to come before the general proxy. * The config is no longer "above" * Build frontend to `/webserver/build` and use `//go:embed` directly in `webserver.go` * make linter happy and remove webui go package * fix "Download webui artifact" step --------- Co-authored-by: Christoph Berg <myon@debian.org> Co-authored-by: Denys Holub <holub.denys20@gmail.com>
1 parent c024225 commit 03d4c7d

File tree

28 files changed

+353
-184
lines changed

28 files changed

+353
-184
lines changed

.github/workflows/build.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ jobs:
6161
uses: actions/upload-artifact@v5
6262
with:
6363
name: webui-build
64-
path: internal/webui/build
64+
path: internal/webserver/build
6565

6666
- name: GolangCI-Lint
6767
uses: golangci/golangci-lint-action@v9
@@ -88,7 +88,7 @@ jobs:
8888
uses: actions/download-artifact@v6
8989
with:
9090
name: webui-build
91-
path: internal/webui/build
91+
path: internal/webserver/build
9292

9393
- name: Setup Protobuf
9494
uses: ./.github/actions/setup-protobuf

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
dist
2020
docs/godoc
2121
site
22+
build
2223

2324
# Generated protobuf files
2425
*.pb.go

cmd/pgwatch/main.go

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ import (
1414
"github.com/cybertec-postgresql/pgwatch/v3/internal/log"
1515
"github.com/cybertec-postgresql/pgwatch/v3/internal/reaper"
1616
"github.com/cybertec-postgresql/pgwatch/v3/internal/webserver"
17-
"github.com/cybertec-postgresql/pgwatch/v3/internal/webui"
1817
)
1918

2019
// setupCloseHandler creates a 'listener' on a new goroutine which will notify the
@@ -104,7 +103,7 @@ func main() {
104103

105104
reaper := reaper.NewReaper(mainCtx, opts)
106105

107-
if _, err = webserver.Init(mainCtx, opts.WebUI, webui.WebUIFs, opts.MetricsReaderWriter,
106+
if _, err = webserver.Init(mainCtx, opts.WebUI, opts.MetricsReaderWriter,
108107
opts.SourcesReaderWriter, reaper); err != nil {
109108
exitCode.Store(cmdopts.ExitCodeWebUIError)
110109
logger.Error("failed to initialize web UI: ", err)

docker/Dockerfile

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,8 @@ ARG GIT_TIME
4545

4646
# Copy source code
4747
COPY . .
48-
# Copy built WebUI from previous stage
49-
COPY --from=webui-builder /webui/build ./internal/webui/build
48+
# Copy built WebUI from previous stage to webserver package
49+
COPY --from=webui-builder /webserver/build ./internal/webserver/build
5050

5151
# Generate protobuf and build the application
5252
RUN go generate ./api/pb/ && \

docker/compose.pgwatch.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ services:
1313
command:
1414
- "--sink=postgresql://pgwatch@postgres:5432/pgwatch_metrics"
1515
- "--sink=prometheus://pgwatch:9187/pgwatch"
16+
# - "--web-base-path=pgwatch"
1617
ports:
1718
- "8080:8080"
1819
- "9187:9187"

docker/demo/Dockerfile

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,8 @@ ARG GIT_TIME
4545

4646
# Copy source code
4747
COPY . .
48-
# Copy built WebUI from previous stage
49-
COPY --from=webui-builder /webui/build ./internal/webui/build
48+
# Copy built WebUI from previous stage to webserver package
49+
COPY --from=webui-builder /webserver/build ./internal/webserver/build
5050

5151
# Generate protobuf and build the application
5252
RUN go generate ./api/pb/ && \

docs/howto/reverse_proxy.md

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
---
2+
title: Running Behind a Reverse Proxy
3+
---
4+
5+
When running pgwatch in production environments, you may want to expose it through a reverse proxy (like Apache, Nginx, or Traefik) on a different path instead of exposing its port directly. This guide shows you how to configure pgwatch for such setups.
6+
7+
## Configuration
8+
9+
### pgwatch Configuration
10+
11+
Use the `--web-base-path` option to specify the base path under which pgwatch should serve its content:
12+
13+
```bash
14+
pgwatch --web-base-path=pgwatch --web-addr=:8432
15+
```
16+
17+
Or using environment variables:
18+
19+
```bash
20+
export PW_WEBBASEPATH=pgwatch
21+
export PW_WEBADDR=:8432
22+
pgwatch
23+
```
24+
25+
The web UI automatically adapts to the configured base path without requiring a rebuild.
26+
27+
WebSockets are used for live log streaming. Make sure your reverse proxy is configured to support WebSocket connections.
28+
29+
### Apache
30+
31+
The `mod_proxy_wstunnel` module is required for the WebSocket proxy.
32+
33+
```apache
34+
<VirtualHost *:443>
35+
ServerName example.com
36+
37+
# Other SSL and domain configuration...
38+
39+
ProxyPass /pgwatch/log ws://localhost:8432/pgwatch/log
40+
ProxyPassReverse /pgwatch/log ws://localhost:8432/pgwatch/log
41+
42+
ProxyPass /pgwatch/ http://localhost:8432/pgwatch/
43+
ProxyPassReverse /pgwatch/ http://localhost:8432/pgwatch/
44+
ProxyPreserveHost On
45+
46+
<Location /pgwatch/>
47+
# Optional: Add authentication
48+
AuthUserFile /etc/apache2/admpasswd
49+
AuthType Basic
50+
AuthName "pgwatch Administration"
51+
<RequireAll>
52+
Require valid-user
53+
</RequireAll>
54+
</Location>
55+
</VirtualHost>
56+
```
57+
58+
### Nginx
59+
60+
WebSocket support is automatic with the proxy configuration shown here. Nginx will upgrade the connection when needed.
61+
62+
```nginx
63+
server {
64+
listen 443 ssl;
65+
server_name example.com;
66+
67+
# Other SSL and domain configuration...
68+
69+
location /pgwatch/ {
70+
proxy_pass http://localhost:8432/pgwatch/;
71+
proxy_set_header Host $host;
72+
proxy_set_header X-Real-IP $remote_addr;
73+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
74+
proxy_set_header X-Forwarded-Proto $scheme;
75+
76+
# Optional: Add authentication
77+
auth_basic "pgwatch Administration";
78+
auth_basic_user_file /etc/nginx/.htpasswd;
79+
}
80+
}
81+
```
82+
83+
## Testing the Configuration
84+
85+
After configuring your reverse proxy and starting pgwatch:
86+
87+
1. Verify the web UI loads: `https://example.com/pgwatch/`
88+
2. Check that API endpoints work: `https://example.com/pgwatch/readiness`
89+
3. Test WebSocket log streaming in the UI's Logs page
90+
4. Verify that navigation between pages works correctly
91+
92+
## Common Issues
93+
94+
### Static Assets Not Loading
95+
96+
Make sure:
97+
98+
- The reverse proxy's `ProxyPass` directive includes the base path
99+
- Both backend (`--web-base-path`) and frontend use the same path (frontend reads it dynamically from backend)
100+
101+
### WebSocket Connection Failures
102+
103+
Ensure:
104+
105+
- Your reverse proxy supports WebSocket upgrades
106+
- The `/log` endpoint is properly proxied
107+
- No firewall rules are blocking WebSocket connections
108+
109+
### Authentication Issues
110+
111+
If using both reverse proxy authentication and pgwatch's built-in authentication:
112+
113+
- The reverse proxy authentication happens first
114+
- Users must authenticate twice (proxy, then pgwatch login)
115+
- Consider disabling pgwatch authentication if using proxy-level auth

docs/reference/cli_env.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,13 @@ It reads the configuration from the specified sources and metrics, then begins c
162162
TCP address in the form 'host:port' to listen on (default: :8080).
163163
ENV: `$PW_WEBADDR`
164164

165+
- `--web-base-path=`
166+
167+
Base path for web UI and API endpoints (e.g., 'pgwatch' for reverse proxy setups). When set, all web endpoints will be served under this path.
168+
ENV: `$PW_WEBBASEPATH`
169+
170+
Example: `--web-base-path=/pgwatch`
171+
165172
- `--web-user=`
166173

167174
Admin username.

internal/webserver/cmdopts.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ const (
99
type CmdOpts struct {
1010
WebDisable string `long:"web-disable" mapstructure:"web-disable" description:"Disable REST API and/or web UI" env:"PW_WEBDISABLE" optional:"true" optional-value:"all" choice:"all" choice:"ui"`
1111
WebAddr string `long:"web-addr" mapstructure:"web-addr" description:"TCP address in the form 'host:port' to listen on" default:":8080" env:"PW_WEBADDR"`
12+
WebBasePath string `long:"web-base-path" mapstructure:"web-base-path" description:"Base path for web UI and API endpoints (e.g., 'pgwatch' for reverse proxy setups)" env:"PW_WEBBASEPATH"`
1213
WebUser string `long:"web-user" mapstructure:"web-user" description:"Admin username" env:"PW_WEBUSER"`
1314
WebPassword string `long:"web-password" mapstructure:"web-password" description:"Admin password" env:"PW_WEBPASSWORD"`
1415
}

internal/webserver/jwt.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,12 @@ type loginReq struct {
1616
Password string `json:"password"`
1717
}
1818

19-
func (Server *WebUIServer) IsCorrectPassword(lr loginReq) bool {
20-
return (Server.WebUser+Server.WebPassword == "") ||
21-
(Server.WebUser == lr.Username && Server.WebPassword == lr.Password)
19+
func (s *WebUIServer) IsCorrectPassword(lr loginReq) bool {
20+
return (s.WebUser+s.WebPassword == "") ||
21+
(s.WebUser == lr.Username && s.WebPassword == lr.Password)
2222
}
2323

24-
func (Server *WebUIServer) handleLogin(w http.ResponseWriter, r *http.Request) {
24+
func (s *WebUIServer) handleLogin(w http.ResponseWriter, r *http.Request) {
2525
var (
2626
err error
2727
lr loginReq
@@ -39,7 +39,7 @@ func (Server *WebUIServer) handleLogin(w http.ResponseWriter, r *http.Request) {
3939
if err = jsoniter.ConfigFastest.NewDecoder(r.Body).Decode(&lr); err != nil {
4040
return
4141
}
42-
if !Server.IsCorrectPassword(lr) {
42+
if !s.IsCorrectPassword(lr) {
4343
http.Error(w, "can not authenticate this user", http.StatusUnauthorized)
4444
return
4545
}

0 commit comments

Comments
 (0)