Skip to content
This repository was archived by the owner on Jun 7, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
@@ -1,3 +1 @@
.github/CODEOWNERS @djchen
.github/workflows/* @djchen
.github/dependabot.yml @djchen
* @djchen
25 changes: 21 additions & 4 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -54,11 +54,28 @@ WORKDIR /opt/opencode-web
COPY --from=build /opt/opencode-web/release/ ./
COPY --from=build /opt/opencode-web/release/runtime/generate-nginx-config.sh /docker-entrypoint.d/40-opencode-web.sh

RUN chmod +x /docker-entrypoint.d/40-opencode-web.sh
RUN sed -i '/^pid /d' /etc/nginx/nginx.conf \
&& rm -f /etc/nginx/conf.d/default.conf \
&& mkdir -p \
/etc/nginx/conf.d \
/opt/opencode-web/runtime-configs \
/var/cache/nginx/client_temp \
/var/cache/nginx/proxy_temp \
/var/cache/nginx/fastcgi_temp \
/var/cache/nginx/uwsgi_temp \
/var/cache/nginx/scgi_temp \
&& chown -R nginx:nginx \
/etc/nginx/conf.d \
/opt/opencode-web/runtime-configs \
/var/cache/nginx \
&& chmod +x /docker-entrypoint.d/40-opencode-web.sh \
&& chmod -R a-w /opt/opencode-web/public

EXPOSE 80
USER nginx:nginx

EXPOSE 8080

HEALTHCHECK --interval=1m --timeout=5s --start-period=15s --retries=3 \
CMD wget -q --spider http://127.0.0.1/health || exit 1
CMD wget -q --spider http://127.0.0.1:8080/health || exit 1

CMD ["nginx", "-g", "daemon off;"]
CMD ["nginx", "-g", "pid /tmp/nginx.pid; daemon off;"]
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ Self-host the [OpenCode](https://opencode.ai) web frontend as a static site with

The container serves the same built app on multiple hostnames, injects the matching backend and title for each hostname, and applies CSS customizations.

The image runs nginx as the non-root `nginx` user and listens on container port `8080`. To expose it on host port 80, map `80:8080`.

## Quick Start
### OpenCode Server
Run `opencode serve` to expose an endpoint that OpenCode clients can use.
Expand All @@ -29,7 +31,7 @@ docker compose up -d
```sh
docker run -d \
--name opencode-web \
-p 8080:80 \
-p 8080:8080 \
-e SERVER_1_HOST=web1.opencode.example.com \
-e SERVER_1_BACKEND=https://api1.opencode.example.com \
-e SERVER_1_NAME='Server 1' \
Expand Down
10 changes: 8 additions & 2 deletions config/nginx.conf.template
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
client_body_temp_path /var/cache/nginx/client_temp;
proxy_temp_path /var/cache/nginx/proxy_temp;
fastcgi_temp_path /var/cache/nginx/fastcgi_temp;
uwsgi_temp_path /var/cache/nginx/uwsgi_temp;
scgi_temp_path /var/cache/nginx/scgi_temp;

server {
listen 80 default_server;
listen [::]:80 default_server;
listen 8080 default_server;
listen [::]:8080 default_server;
server_name _;
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
add_header Content-Security-Policy "default-src 'self'; base-uri 'self'; connect-src * data:; font-src 'self' data:; frame-ancestors 'none'; img-src 'self' data: https:; media-src 'self' data:; object-src 'none'; script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'" always;
Expand Down
2 changes: 1 addition & 1 deletion docker-compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ services:
web:
image: ghcr.io/djchen/opencode-web-docker
ports:
- 8080:80
- 8080:8080
environment:
SERVER_1_HOST: web1.opencode.example.com
SERVER_1_BACKEND: https://api1.opencode.example.com
Expand Down
7 changes: 4 additions & 3 deletions runtime/generate-nginx-config.sh
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ runtime_bundle_path="$runtime_root/runtime/runtime-bundle.js"
nginx_template_path="$runtime_root/config/nginx.conf.template"
nginx_config_path="/etc/nginx/conf.d/default.conf"
nginx_servers_marker="# OPENCODE_WEB_GENERATED_SERVERS"
nginx_listen_port="8080"

if [ ! -r "$runtime_bundle_path" ]; then
die "Missing runtime bundle at $runtime_bundle_path"
Expand Down Expand Up @@ -216,8 +217,8 @@ write_server_block() {
cat <<EOF

server {
listen 80;
listen [::]:80;
listen $nginx_listen_port;
listen [::]:$nginx_listen_port;
server_name $host;
root /opt/opencode-web/public;
index index.html;
Expand Down Expand Up @@ -248,8 +249,8 @@ $(write_asset_headers)
EOF
}

rm -rf "$runtime_config_root"
mkdir -p "$runtime_config_root"
rm -f "$runtime_config_root"/*.js

generated_servers_path="$(mktemp)"
trap 'rm -f "$generated_servers_path"' EXIT
Expand Down
39 changes: 29 additions & 10 deletions scripts/test-runtime-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,13 @@ async function expectFinalImageLayout(
"test -f /opt/opencode-web/runtime/generate-nginx-config.sh",
"test -x /docker-entrypoint.d/40-opencode-web.sh",
"test -f /opt/opencode-web/runtime/runtime-bundle.js",
'test "$(id -un)" = nginx',
'test "$(id -gn)" = nginx',
'test "$(id -u)" -ne 0',
"test -w /etc/nginx/conf.d",
"test -w /opt/opencode-web/runtime-configs",
"test -w /var/cache/nginx/client_temp",
"test ! -w /opt/opencode-web/public",
].join(" && "),
]);
}
Expand Down Expand Up @@ -224,7 +231,7 @@ async function withNginxContainer(
"wget",
"-q",
"--spider",
"http://127.0.0.1/health",
"http://127.0.0.1:8080/health",
]);
if (result.exitCode === 0) return containerId;
await Bun.sleep(1000);
Expand Down Expand Up @@ -437,6 +444,18 @@ try {
"/docker-entrypoint.d/40-opencode-web.sh && test -s /opt/opencode-web/runtime-configs/web1.opencode.example.com.js && test ! -e /opt/opencode-web/public/runtime-config.js",
),
);
await expectSuccess(
"clean stale generated runtime config files",
dockerRun(
"-e",
"SERVER_1_HOST=web1.opencode.example.com",
"-e",
"SERVER_1_BACKEND=http://api1.opencode.example.com",
"sh",
"-lc",
"touch /opt/opencode-web/runtime-configs/stale.js && /docker-entrypoint.d/40-opencode-web.sh && test -d /opt/opencode-web/runtime-configs && test ! -e /opt/opencode-web/runtime-configs/stale.js && test -s /opt/opencode-web/runtime-configs/web1.opencode.example.com.js",
),
);
await expectSuccess(
"generate valid host-based runtime payloads",
dockerRun(
Expand Down Expand Up @@ -659,7 +678,7 @@ try {
"--header=Host: web1.opencode.example.com",
"-O",
"-",
"http://127.0.0.1/runtime-config.js",
"http://127.0.0.1:8080/runtime-config.js",
],
);
await expectGeneratedRuntimeConfigApplies(
Expand All @@ -677,7 +696,7 @@ try {
"--header=Host: web2.opencode.example.com",
"-O",
"-",
"http://127.0.0.1/runtime-config.js",
"http://127.0.0.1:8080/runtime-config.js",
],
);
await expectGeneratedRuntimeConfigApplies(
Expand All @@ -692,10 +711,10 @@ try {
containerId,
"wget",
"-q",
"--header=Host: web2.opencode.example.com:80",
"--header=Host: web2.opencode.example.com:8080",
"-O",
"-",
"http://127.0.0.1/runtime-config.js",
"http://127.0.0.1:8080/runtime-config.js",
],
);
await expectSuccess("nginx unmatched host returns 404 except health", [
Expand All @@ -704,23 +723,23 @@ try {
containerId,
"sh",
"-lc",
'wget -q --spider --header="Host: unmatched.example.com" http://127.0.0.1/health && ! wget -q --spider --header="Host: unmatched.example.com" http://127.0.0.1/runtime-config.js && ! wget -q --spider --header="Host: unmatched.example.com" http://127.0.0.1/future/opencode/route',
'wget -q --spider --header="Host: unmatched.example.com" http://127.0.0.1:8080/health && ! wget -q --spider --header="Host: unmatched.example.com" http://127.0.0.1:8080/runtime-config.js && ! wget -q --spider --header="Host: unmatched.example.com" http://127.0.0.1:8080/future/opencode/route',
]);
await expectSuccess("nginx configured host SPA route returns app shell", [
"docker",
"exec",
containerId,
"sh",
"-lc",
'wget -q --header="Host: web2.opencode.example.com" -O - http://127.0.0.1/future/opencode/route | grep -q "/runtime-config.js"',
'wget -q --header="Host: web2.opencode.example.com" -O - http://127.0.0.1:8080/future/opencode/route | grep -q "/runtime-config.js"',
]);
await expectSuccess("nginx missing static file returns 404", [
"docker",
"exec",
containerId,
"sh",
"-lc",
'! wget -q --spider --header="Host: web2.opencode.example.com" http://127.0.0.1/missing.js',
'! wget -q --spider --header="Host: web2.opencode.example.com" http://127.0.0.1:8080/missing.js',
]);
await expectSuccess(
"configured host app shell has no-store and CSP headers",
Expand All @@ -730,7 +749,7 @@ try {
containerId,
"sh",
"-lc",
'headers="$(wget -qS --header="Host: web1.opencode.example.com" -O /dev/null http://127.0.0.1/ 2>&1)"; printf "%s\n" "$headers" | grep -qi "cache-control.*no-store" && printf "%s\n" "$headers" | grep -qi "content-security-policy"',
'headers="$(wget -qS --header="Host: web1.opencode.example.com" -O /dev/null http://127.0.0.1:8080/ 2>&1)"; printf "%s\n" "$headers" | grep -qi "cache-control.*no-store" && printf "%s\n" "$headers" | grep -qi "content-security-policy"',
],
);
await expectSuccess("hashed assets have long-lived cache headers", [
Expand All @@ -742,7 +761,7 @@ try {
[
"set -- /opt/opencode-web/public/assets/*",
`asset="\${1##*/}"`,
'headers="$(wget -qS --header="Host: web1.opencode.example.com" -O /dev/null "http://127.0.0.1/assets/$asset" 2>&1)"',
'headers="$(wget -qS --header="Host: web1.opencode.example.com" -O /dev/null "http://127.0.0.1:8080/assets/$asset" 2>&1)"',
'printf "%s\n" "$headers" | grep -qi "cache-control.*immutable"',
'printf "%s\n" "$headers" | grep -qi "content-security-policy"',
].join(" && "),
Expand Down
48 changes: 44 additions & 4 deletions tests/static-csp.contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,13 +198,38 @@ export const staticCspContracts: Contract[] = [
checks: [
match(
"nginxConfigTemplate",
/listen 80 default_server;/,
"expected nginx default server to listen on IPv4 port 80",
/listen 8080 default_server;/,
"expected nginx default server to listen on IPv4 port 8080",
),
match(
"nginxConfigTemplate",
/listen \[::\]:80 default_server;/,
"expected nginx default server to listen on IPv6 port 80",
/listen \[::\]:8080 default_server;/,
"expected nginx default server to listen on IPv6 port 8080",
),
match(
"nginxConfigTemplate",
/client_body_temp_path \/var\/cache\/nginx\/client_temp;/,
"expected nginx client temp path to use a writable non-root directory",
),
match(
"nginxConfigTemplate",
/proxy_temp_path \/var\/cache\/nginx\/proxy_temp;/,
"expected nginx proxy temp path to use a writable non-root directory",
),
match(
"nginxConfigTemplate",
/fastcgi_temp_path \/var\/cache\/nginx\/fastcgi_temp;/,
"expected nginx fastcgi temp path to use a writable non-root directory",
),
match(
"nginxConfigTemplate",
/uwsgi_temp_path \/var\/cache\/nginx\/uwsgi_temp;/,
"expected nginx uwsgi temp path to use a writable non-root directory",
),
match(
"nginxConfigTemplate",
/scgi_temp_path \/var\/cache\/nginx\/scgi_temp;/,
"expected nginx scgi temp path to use a writable non-root directory",
),
match(
"nginxConfigTemplate",
Expand All @@ -221,6 +246,21 @@ export const staticCspContracts: Contract[] = [
/^# OPENCODE_WEB_GENERATED_SERVERS$/m,
"expected nginx template to contain generated server marker",
),
match(
"runtimeGenerator",
/nginx_listen_port="8080"/,
"expected generated nginx server blocks to use container port 8080",
),
match(
"runtimeGenerator",
/listen \$nginx_listen_port;/,
"expected generated nginx IPv4 listeners to use the configured listen port",
),
match(
"runtimeGenerator",
/listen \[::\]:\$nginx_listen_port;/,
"expected generated nginx IPv6 listeners to use the configured listen port",
),
match(
"runtimeGenerator",
/server_name \$host;/,
Expand Down