feat(docker): add GHCR and Docker Hub image publishing (#4250)

* Add GHCR and Docker Hub image publishing with fully runtime-configurable pull-first Docker setup (#1)

* feat: add container image publishing workflow and pull-based compose setup

Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/23c31167-9e15-4d44-ab89-f267b8cd6304

* refine docker publishing workflow and pull-first compose docs

Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/23c31167-9e15-4d44-ab89-f267b8cd6304

* chore: temporarily expose docker publish run results for pr branch

Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/c946a2f2-2219-4dea-a829-61b287bc4859

* fix: update pinned SHAs for setup-qemu-action and setup-buildx-action to v3 heads

Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/0db6957e-476b-48e0-acf3-bee6964c3b32

Co-authored-by: pourmand1376 <32064808+pourmand1376@users.noreply.github.com>

* chore: switch all workflow action refs from SHA pins to stable version tags

Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/19334b37-9b4c-45df-9c3c-81a497cef8e8

Co-authored-by: pourmand1376 <32064808+pourmand1376@users.noreply.github.com>

* fix: restore missing `with:` blocks lost during SHA-to-tag substitution

Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/f204f742-5b7d-4f05-9647-03b9db86ea3d

Co-authored-by: pourmand1376 <32064808+pourmand1376@users.noreply.github.com>

* fix(ci): checkout submodules for docker image workflow

Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/72309e8a-6c7c-4004-902a-565f67e5c15f

Co-authored-by: pourmand1376 <32064808+pourmand1376@users.noreply.github.com>

* Apply suggestions from code review

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* feat(docker): support runtime client env for pulled web image

* refactor(web): serve runtime config via script endpoint

* fix(web): escape runtime config script payload

* fix: align docker runtime config with internal/public backend urls

Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/3bbcb608-6202-4f9e-b288-5c95a259da93

Co-authored-by: pourmand1376 <32064808+pourmand1376@users.noreply.github.com>

* chore: align published workflow tags with docs

* docs: clarify runtime config precedence and linux host mapping

Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/3bbcb608-6202-4f9e-b288-5c95a259da93

* refactor: move storage/quota config from build args to runtime env

Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/da9b749e-0b1c-47b9-b474-5009765b6ea6

Co-authored-by: pourmand1376 <32064808+pourmand1376@users.noreply.github.com>

* fix: load runtime config in pages router app shell

Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/b375132c-8317-4c98-b437-ae48c0153e3d

Co-authored-by: pourmand1376 <32064808+pourmand1376@users.noreply.github.com>

* fix: apply biome formatting for runtime config quota helpers

Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/778f5d75-884e-401b-b7cb-4ab40bc64a11

Co-authored-by: pourmand1376 <32064808+pourmand1376@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: pourmand1376 <32064808+pourmand1376@users.noreply.github.com>
Co-authored-by: Amir Pourmand <pourmand1376@gmail.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* fix: S3_PUBLIC_ENDPOINT, _document.tsx for beforeInteractive, runtimeConfig fallbacks, README port (#2)

Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/4f92f818-c008-4caa-9684-d530500b5fb2

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: pourmand1376 <32064808+pourmand1376@users.noreply.github.com>

* fix: reformat runtimeConfig.ts to satisfy biome formatter (#3)

Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/4eea77f4-67ab-4428-b59e-0b21be988037

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: pourmand1376 <32064808+pourmand1376@users.noreply.github.com>

---------

Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Amir Pourmand
2026-05-21 21:27:35 +03:30
committed by GitHub
parent ae81cd0151
commit 9ad43aa8b2
15 changed files with 352 additions and 53 deletions
+138
View File
@@ -0,0 +1,138 @@
name: Publish Docker image
on:
workflow_dispatch:
push:
branches:
- main
release:
types:
- published
concurrency:
group: publish-docker-image-${{ github.event.release.tag_name || github.ref }}
cancel-in-progress: true
permissions:
contents: read
packages: write
jobs:
publish:
runs-on: ubuntu-latest
env:
BUILD_ARGS: |
NEXT_PUBLIC_APP_PLATFORM=web
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Detect Docker Hub credentials
id: dockerhub
run: |
if [ -n "${{ secrets.DOCKERHUB_USERNAME }}" ] && [ -n "${{ secrets.DOCKERHUB_TOKEN }}" ]; then
echo "enabled=true" >> "$GITHUB_OUTPUT"
else
echo "enabled=false" >> "$GITHUB_OUTPUT"
fi
- name: Log in to Docker Hub
if: steps.dockerhub.outputs.enabled == 'true'
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract image metadata (GHCR only)
id: meta-ghcr
if: steps.dockerhub.outputs.enabled != 'true'
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository_owner }}/readest
tags: |
type=raw,value=main,enable={{is_default_branch}}
type=raw,value=latest,enable=${{ github.event_name == 'release' }}
type=raw,value=${{ github.event.release.tag_name }},enable=${{ github.event_name == 'release' }}
type=sha,prefix=sha-
- name: Extract image metadata (GHCR + Docker Hub)
id: meta-all
if: steps.dockerhub.outputs.enabled == 'true'
uses: docker/metadata-action@v5
with:
images: |
ghcr.io/${{ github.repository_owner }}/readest
docker.io/${{ secrets.DOCKERHUB_USERNAME }}/readest
tags: |
type=raw,value=main,enable={{is_default_branch}}
type=raw,value=latest,enable=${{ github.event_name == 'release' }}
type=raw,value=${{ github.event.release.tag_name }},enable=${{ github.event_name == 'release' }}
type=sha,prefix=sha-
- name: Build and push (GHCR only)
id: build-ghcr
if: steps.dockerhub.outputs.enabled != 'true'
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
target: production-stage
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta-ghcr.outputs.tags }}
labels: ${{ steps.meta-ghcr.outputs.labels }}
build-args: ${{ env.BUILD_ARGS }}
- name: Published image summary (GHCR only)
if: steps.dockerhub.outputs.enabled != 'true'
run: |
echo "## Published Images" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "Registry: GHCR" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "Tags:" >> "$GITHUB_STEP_SUMMARY"
echo '```' >> "$GITHUB_STEP_SUMMARY"
echo "${{ steps.meta-ghcr.outputs.tags }}" >> "$GITHUB_STEP_SUMMARY"
echo '```' >> "$GITHUB_STEP_SUMMARY"
echo "Digest: \`${{ steps.build-ghcr.outputs.digest }}\`" >> "$GITHUB_STEP_SUMMARY"
- name: Build and push (GHCR + Docker Hub)
id: build-all
if: steps.dockerhub.outputs.enabled == 'true'
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
target: production-stage
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta-all.outputs.tags }}
labels: ${{ steps.meta-all.outputs.labels }}
build-args: ${{ env.BUILD_ARGS }}
- name: Published image summary (GHCR + Docker Hub)
if: steps.dockerhub.outputs.enabled == 'true'
run: |
echo "## Published Images" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "Registries: GHCR, Docker Hub" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "Tags:" >> "$GITHUB_STEP_SUMMARY"
echo '```' >> "$GITHUB_STEP_SUMMARY"
echo "${{ steps.meta-all.outputs.tags }}" >> "$GITHUB_STEP_SUMMARY"
echo '```' >> "$GITHUB_STEP_SUMMARY"
echo "Digest: \`${{ steps.build-all.outputs.digest }}\`" >> "$GITHUB_STEP_SUMMARY"
@@ -19,6 +19,7 @@ beforeEach(() => {
Object.assign(env, originalEnv);
// Clean up any window globals we set
delete (window as unknown as Record<string, unknown>)['__READEST_CLI_ACCESS'];
delete (window as unknown as Record<string, unknown>)['__READEST_RUNTIME_CONFIG'];
});
describe('environment', () => {
@@ -106,6 +107,22 @@ describe('environment', () => {
// ── getBaseUrl ─────────────────────────────────────────────────
describe('getBaseUrl', () => {
test('returns runtime-configured apiBaseUrl when set', async () => {
window.__READEST_RUNTIME_CONFIG = {
apiBaseUrl: 'https://runtime-api.example.com',
};
env['NEXT_PUBLIC_API_BASE_URL'] = 'https://custom-api.example.com';
const { getBaseUrl } = await import('@/services/environment');
expect(getBaseUrl()).toBe('https://runtime-api.example.com');
});
test('returns API_BASE_URL when set', async () => {
env['API_BASE_URL'] = 'https://runtime-api.example.com';
delete env['NEXT_PUBLIC_API_BASE_URL'];
const { getBaseUrl } = await import('@/services/environment');
expect(getBaseUrl()).toBe('https://runtime-api.example.com');
});
test('returns NEXT_PUBLIC_API_BASE_URL when set', async () => {
env['NEXT_PUBLIC_API_BASE_URL'] = 'https://custom-api.example.com';
const { getBaseUrl } = await import('@/services/environment');
+6 -4
View File
@@ -1,5 +1,6 @@
import * as React from 'react';
import type { Metadata, Viewport } from 'next';
import Script from 'next/script';
import { ViewTransitions } from 'next-view-transitions';
import { EnvProvider } from '@/context/EnvContext';
import Providers from '@/components/Providers';
@@ -128,11 +129,12 @@ export default function RootLayout({ children }: { children: React.ReactNode })
lang='en'
className={process.env['NEXT_PUBLIC_APP_PLATFORM'] === 'tauri' ? 'edge-to-edge' : ''}
>
{shouldInjectDevHmrPatch ? (
<head>
<head>
<Script src='/runtime-config.js' strategy='beforeInteractive' />
{shouldInjectDevHmrPatch ? (
<script dangerouslySetInnerHTML={{ __html: devHmrPatchScript }} />
</head>
) : null}
) : null}
</head>
<body>
<ViewTransitions>
<EnvProvider>
@@ -0,0 +1,18 @@
import { NextResponse } from 'next/server';
import { getServerRuntimeConfig } from '@/services/runtimeConfig';
export const dynamic = 'force-dynamic';
export function GET() {
const serializedConfig = JSON.stringify(getServerRuntimeConfig())
.replace(/</g, '\\u003c')
.replace(/\u2028/g, '\\u2028')
.replace(/\u2029/g, '\\u2029');
const script = `window.__READEST_RUNTIME_CONFIG=${serializedConfig};`;
return new NextResponse(script, {
headers: {
'Content-Type': 'application/javascript; charset=utf-8',
'Cache-Control': 'no-store, max-age=0',
},
});
}
+17
View File
@@ -0,0 +1,17 @@
import { Html, Head, Main, NextScript } from 'next/document';
import Script from 'next/script';
export default function Document() {
return (
<Html lang='en'>
<Head />
<body>
{/* beforeInteractive must live in _document (not _app) to guarantee the
script runs before any client-side module evaluation. */}
<Script src='/runtime-config.js' strategy='beforeInteractive' />
<Main />
<NextScript />
</body>
</Html>
);
}
+6 -1
View File
@@ -1,5 +1,6 @@
import { AppService } from '@/types/system';
import { READEST_NODE_BASE_URL, READEST_WEB_BASE_URL } from './constants';
import { getRuntimeConfig } from './runtimeConfig';
declare global {
interface Window {
@@ -11,7 +12,11 @@ export const isTauriAppPlatform = () => process.env['NEXT_PUBLIC_APP_PLATFORM']
export const isWebAppPlatform = () => process.env['NEXT_PUBLIC_APP_PLATFORM'] === 'web';
export const hasCli = () => window.__READEST_CLI_ACCESS === true;
export const isPWA = () => window.matchMedia('(display-mode: standalone)').matches;
export const getBaseUrl = () => process.env['NEXT_PUBLIC_API_BASE_URL'] ?? READEST_WEB_BASE_URL;
export const getBaseUrl = () =>
getRuntimeConfig()?.apiBaseUrl ??
process.env['API_BASE_URL'] ??
process.env['NEXT_PUBLIC_API_BASE_URL'] ??
READEST_WEB_BASE_URL;
export const getNodeBaseUrl = () =>
process.env['NEXT_PUBLIC_NODE_BASE_URL'] ?? READEST_NODE_BASE_URL;
@@ -0,0 +1,45 @@
export interface ReadestRuntimeConfig {
supabaseUrl?: string;
supabaseAnonKey?: string;
apiBaseUrl?: string;
objectStorageType?: string;
storageFixedQuota?: number;
translationFixedQuota?: number;
}
declare global {
interface Window {
__READEST_RUNTIME_CONFIG?: ReadestRuntimeConfig;
}
}
export const getRuntimeConfig = () =>
typeof window === 'undefined' ? undefined : window.__READEST_RUNTIME_CONFIG;
export const getServerRuntimeConfig = (): ReadestRuntimeConfig => ({
// Browser runtime config should prefer a public Supabase URL when provided.
// SUPABASE_URL remains as a backward-compatible fallback for non-split setups.
supabaseUrl:
process.env['SUPABASE_PUBLIC_URL'] ??
process.env['NEXT_PUBLIC_SUPABASE_URL'] ??
process.env['SUPABASE_URL'],
supabaseAnonKey: process.env['SUPABASE_ANON_KEY'] ?? process.env['NEXT_PUBLIC_SUPABASE_ANON_KEY'],
apiBaseUrl:
process.env['API_BASE_URL'] ??
process.env['NEXT_PUBLIC_API_BASE_URL'] ??
process.env['SITE_URL'],
// These were previously baked as NEXT_PUBLIC_* build args; now read from runtime env so
// the published image can be configured without rebuilding.
objectStorageType:
process.env['OBJECT_STORAGE_TYPE'] ?? process.env['NEXT_PUBLIC_OBJECT_STORAGE_TYPE'],
storageFixedQuota: (() => {
const raw =
process.env['STORAGE_FIXED_QUOTA'] ?? process.env['NEXT_PUBLIC_STORAGE_FIXED_QUOTA'];
return raw ? parseInt(raw, 10) : undefined;
})(),
translationFixedQuota: (() => {
const raw =
process.env['TRANSLATION_FIXED_QUOTA'] ?? process.env['NEXT_PUBLIC_TRANSLATION_FIXED_QUOTA'];
return raw ? parseInt(raw, 10) : undefined;
})(),
});
+7 -2
View File
@@ -4,6 +4,7 @@ import { UserPlan } from '@/types/quota';
import { DEFAULT_DAILY_TRANSLATION_QUOTA, DEFAULT_STORAGE_QUOTA } from '@/services/constants';
import { isWebAppPlatform } from '@/services/environment';
import { getDailyUsage } from '@/services/translators/utils';
import { getRuntimeConfig } from '@/services/runtimeConfig';
interface Token {
plan: UserPlan;
@@ -36,7 +37,9 @@ export const getStoragePlanData = (token: string) => {
const plan = data['plan'] || 'free';
const usage = data['storage_usage_bytes'] || 0;
const purchasedQuota = data['storage_purchased_bytes'] || 0;
const fixedQuota = parseInt(process.env['NEXT_PUBLIC_STORAGE_FIXED_QUOTA'] || '0');
const runtimeConfig = getRuntimeConfig();
const fixedQuota =
runtimeConfig?.storageFixedQuota ?? parseInt(process.env['STORAGE_FIXED_QUOTA'] ?? '0');
const planQuota = fixedQuota || DEFAULT_STORAGE_QUOTA[plan] || DEFAULT_STORAGE_QUOTA['free'];
const quota = planQuota + purchasedQuota;
@@ -48,7 +51,9 @@ export const getStoragePlanData = (token: string) => {
};
export const getTranslationQuota = (plan: UserPlan): number => {
const fixedQuota = parseInt(process.env['NEXT_PUBLIC_TRANSLATION_FIXED_QUOTA'] || '0');
const runtimeConfig = getRuntimeConfig();
const fixedQuota =
runtimeConfig?.translationFixedQuota ?? parseInt(process.env['TRANSLATION_FIXED_QUOTA'] ?? '0');
return (
fixedQuota || DEFAULT_DAILY_TRANSLATION_QUOTA[plan] || DEFAULT_DAILY_TRANSLATION_QUOTA['free']
);
+23 -10
View File
@@ -9,18 +9,34 @@ import {
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
const S3_ENDPOINT = process.env['S3_ENDPOINT'] || '';
// S3_PUBLIC_ENDPOINT is the MinIO URL reachable by browsers (e.g. http://<host-ip>:9000).
// When unset it falls back to S3_ENDPOINT so single-endpoint setups are unaffected.
const S3_PUBLIC_ENDPOINT = process.env['S3_PUBLIC_ENDPOINT'] || S3_ENDPOINT;
const S3_REGION = process.env['S3_REGION'] || 'auto';
const S3_ACCESS_KEY_ID = process.env['S3_ACCESS_KEY_ID'] || '';
const S3_SECRET_ACCESS_KEY = process.env['S3_SECRET_ACCESS_KEY'] || '';
const s3ClientCredentials = {
accessKeyId: S3_ACCESS_KEY_ID,
secretAccessKey: S3_SECRET_ACCESS_KEY,
};
// Internal client used for server-side SDK calls (PutObject, CopyObject, etc.)
export const s3Client = new S3Client({
forcePathStyle: true,
region: S3_REGION,
endpoint: S3_ENDPOINT,
credentials: {
accessKeyId: S3_ACCESS_KEY_ID,
secretAccessKey: S3_SECRET_ACCESS_KEY,
},
credentials: s3ClientCredentials,
});
// Signing client uses S3_PUBLIC_ENDPOINT so presigned URLs contain a hostname
// that browsers can reach (S3_ENDPOINT may be an internal docker hostname like
// "minio:9000" which is not resolvable outside the docker network).
const s3SigningClient = new S3Client({
forcePathStyle: true,
region: S3_REGION,
endpoint: S3_PUBLIC_ENDPOINT,
credentials: s3ClientCredentials,
});
export const s3Storage = {
@@ -29,10 +45,7 @@ export const s3Storage = {
forcePathStyle: true,
region: S3_REGION,
endpoint: S3_ENDPOINT,
credentials: {
accessKeyId: S3_ACCESS_KEY_ID,
secretAccessKey: S3_SECRET_ACCESS_KEY,
},
credentials: s3ClientCredentials,
});
},
@@ -41,7 +54,7 @@ export const s3Storage = {
Bucket: bucketName,
Key: fileKey,
});
const downloadUrl = await getSignedUrl(s3Client, getCommand, {
const downloadUrl = await getSignedUrl(s3SigningClient, getCommand, {
expiresIn: expiresIn,
});
return downloadUrl;
@@ -61,7 +74,7 @@ export const s3Storage = {
ContentLength: contentLength,
});
const uploadUrl = await getSignedUrl(s3Client, putCommand, {
const uploadUrl = await getSignedUrl(s3SigningClient, putCommand, {
expiresIn: expiresIn,
signableHeaders,
});
+6 -6
View File
@@ -1,10 +1,10 @@
import { getRuntimeConfig } from '@/services/runtimeConfig';
type ObjectStorageType = 'r2' | 's3';
export const getStorageType = (): ObjectStorageType => {
// TODO: do not expose storage type to client
if (process.env['NEXT_PUBLIC_OBJECT_STORAGE_TYPE']) {
return process.env['NEXT_PUBLIC_OBJECT_STORAGE_TYPE'] as ObjectStorageType;
} else {
return 'r2';
}
// Client: read from runtime config injected via /runtime-config.js at container start.
// Server: fall back to the OBJECT_STORAGE_TYPE process env var.
const runtimeType = getRuntimeConfig()?.objectStorageType ?? process.env['OBJECT_STORAGE_TYPE'];
return (runtimeType as ObjectStorageType) || 'r2';
};
+3
View File
@@ -1,10 +1,13 @@
import { createClient } from '@supabase/supabase-js';
import { getRuntimeConfig } from '@/services/runtimeConfig';
const supabaseUrl =
getRuntimeConfig()?.supabaseUrl ||
process.env['SUPABASE_URL'] ||
process.env['NEXT_PUBLIC_SUPABASE_URL'] ||
atob(process.env['NEXT_PUBLIC_DEFAULT_SUPABASE_URL_BASE64']!);
const supabaseAnonKey =
getRuntimeConfig()?.supabaseAnonKey ||
process.env['SUPABASE_ANON_KEY'] ||
process.env['NEXT_PUBLIC_SUPABASE_ANON_KEY'] ||
atob(process.env['NEXT_PUBLIC_DEFAULT_SUPABASE_KEY_BASE64']!);
+4 -1
View File
@@ -1,5 +1,7 @@
# ip used by frontend/client-side urls to access the backend
# (used in compose for SUPABASE_PUBLIC_URL)
HOST_IP=localhost
READEST_IMAGE=ghcr.io/readest/readest:latest
# db(psql) config
# change to strong password, min 32 chars
@@ -42,10 +44,11 @@ SMTP_SENDER_NAME=Readest
PGRST_DB_SCHEMAS=public,graphql_public
# s3 storage through MinIO
OBJECT_STORAGE_TYPE=s3
MINIO_ROOT_USER=minioadmin
MINIO_ROOT_PASSWORD=minioadmin-secret-password
S3_BUCKET_NAME=readest-files
# 1GB fixed quota for storage and 50k for translations
STORAGE_FIXED_QUOTA=1073741824
TRANSLATION_FIXED_QUOTA=50000
TRANSLATION_FIXED_QUOTA=50000
+42 -16
View File
@@ -4,7 +4,7 @@
| service | Image | Description |
| --------------- | --------------------------- | ------------------------------------------------- |
| **client** | from `../Dockerfile` | readest frontend |
| **client** | `ghcr.io/readest/readest` | readest frontend |
| **db** | `supabase/postgres` | psql db with supabase extensions |
| **kong** | `kong:2.8.1` | api gateway routing requests to supabase services |
| **auth** | `supabase/gotrue:v2.185.0` | auth service (email, JWT) |
@@ -17,7 +17,7 @@
| Port | Service |
| ------ | ---------------- |
| `3000` | readest |
| `7000` | kong API gateway |
| `8000` | kong API gateway |
| `9000` | MinIO S3 API |
| `9001` | MinIO console UI |
@@ -40,16 +40,40 @@ update `docker/.env`:
- `SERVICE_ROLE_KEY` payload: `{"role": "service_role"}`
- set `MINIO_ROOT_PASSWORD` to a strong password
### 2. Start the Stack
### 2. Start the Stack (pull prebuilt client image)
run from the `docker/` directory:
```bash
cd docker
docker compose up --build -d
docker compose up -d
```
the client image is built locally on first run. subsequent starts reuse the cached image.
this pulls `${READEST_IMAGE}` (default: `ghcr.io/readest/readest:latest`) instead of building the client locally.
the web client now reads `SUPABASE_PUBLIC_URL`, `SUPABASE_ANON_KEY`, `API_BASE_URL`, `OBJECT_STORAGE_TYPE`, `STORAGE_FIXED_QUOTA`, and `TRANSLATION_FIXED_QUOTA` from runtime
container env, so custom self-hosted values work with pulled images.
if you prefer Docker Hub, set `READEST_IMAGE` in `docker/.env`, for example:
```env
READEST_IMAGE=docker.io/your-dockerhub-username/readest:latest
```
replace `your-dockerhub-username` with the Docker Hub namespace that publishes your `readest` image.
for official images, use the namespace configured for this repository's Docker Hub publishing secrets.
published tags:
- `latest`: published from release events
- `<release-tag>` (for example `v1.2.3`): published from release events
- `main`: rolling image from the default branch
- `sha-<commit>`: immutable commit tag
### Build locally instead of pulling
```bash
cd docker
docker compose -f compose.yaml -f compose.build.yaml up --build -d
```
### 3. Access
@@ -58,7 +82,7 @@ the client image is built locally on first run. subsequent starts reuse the cach
### Hot Reload (development)
to develop using the compose stack, set the build target on `client` to `development-stage`, which'll runs the next.js dev server. to enable hot reload, uncomment the `volumes` block in the `client` service in `compose.yaml`:
to develop using the compose stack, use local builds (`compose.yaml` + `compose.build.yaml`) and set the build target on `client` to `development-stage` in `compose.build.yaml`, which runs the next.js dev server. to enable hot reload, uncomment the `volumes` block in the `client` service in `compose.yaml`:
```yaml
volumes:
@@ -90,18 +114,10 @@ docker compose down -v
## Building the Dockerfile standalone
the `Dockerfile` requires Build args for the next.js public env vars (they are inlined at build time)
```bash
docker build \
--target production-stage \
--build-arg NEXT_PUBLIC_SUPABASE_URL=http://localhost:7000 \
--build-arg NEXT_PUBLIC_SUPABASE_ANON_KEY=<anon-key> \
--build-arg NEXT_PUBLIC_APP_PLATFORM=web \
--build-arg NEXT_PUBLIC_API_BASE_URL=http://localhost:3000 \
--build-arg NEXT_PUBLIC_OBJECT_STORAGE_TYPE=s3 \
--build-arg NEXT_PUBLIC_STORAGE_FIXED_QUOTA=1073741824 \
--build-arg NEXT_PUBLIC_TRANSLATION_FIXED_QUOTA=50000 \
-t readest-client \
.
```
@@ -110,13 +126,23 @@ run the built image:
```bash
docker run -p 3000:3000 \
-e SUPABASE_URL=http://kong:8000 \
-e SUPABASE_URL=http://host.docker.internal:8000 \
-e SUPABASE_PUBLIC_URL=http://localhost:8000 \
-e SUPABASE_ANON_KEY=<anon-key> \
-e SUPABASE_ADMIN_KEY=<service-role-key> \
-e S3_ENDPOINT=http://localhost:9000 \
-e API_BASE_URL=http://localhost:3000 \
-e OBJECT_STORAGE_TYPE=s3 \
-e S3_ENDPOINT=http://host.docker.internal:9000 \
-e S3_PUBLIC_ENDPOINT=http://localhost:9000 \
-e S3_REGION=us-east-1 \
-e S3_BUCKET_NAME=readest-files \
-e S3_ACCESS_KEY_ID=<minio-user> \
-e S3_SECRET_ACCESS_KEY=<minio-password> \
-e STORAGE_FIXED_QUOTA=1073741824 \
-e TRANSLATION_FIXED_QUOTA=50000 \
readest-client
```
on Linux, some Docker setups do not resolve `host.docker.internal` by default.
in that case, either replace it with your host IP or run with:
`--add-host=host.docker.internal:host-gateway`.
+10
View File
@@ -0,0 +1,10 @@
services:
client:
build:
context: ..
target: production-stage
args:
NEXT_PUBLIC_SUPABASE_URL: http://${HOST_IP}:${KONG_HTTP_PORT:-8000}
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${ANON_KEY}
NEXT_PUBLIC_APP_PLATFORM: web
NEXT_PUBLIC_API_BASE_URL: http://${HOST_IP}:3000
+10 -13
View File
@@ -119,17 +119,8 @@ services:
client:
container_name: readest-client
build:
context: ..
target: production-stage
args:
NEXT_PUBLIC_SUPABASE_URL: http://${HOST_IP}:${KONG_HTTP_PORT:-8000}
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${ANON_KEY}
NEXT_PUBLIC_APP_PLATFORM: web
NEXT_PUBLIC_API_BASE_URL: http://${HOST_IP}:3000
NEXT_PUBLIC_OBJECT_STORAGE_TYPE: s3
NEXT_PUBLIC_STORAGE_FIXED_QUOTA: ${STORAGE_FIXED_QUOTA}
NEXT_PUBLIC_TRANSLATION_FIXED_QUOTA: ${TRANSLATION_FIXED_QUOTA}
image: ${READEST_IMAGE:-ghcr.io/readest/readest:latest}
pull_policy: missing
restart: unless-stopped
ports:
- "3000:3000"
@@ -143,13 +134,19 @@ services:
# - /app/packages/foliate-js/node_modules
environment:
SUPABASE_URL: http://kong:8000
SUPABASE_PUBLIC_URL: http://${HOST_IP}:${KONG_HTTP_PORT:-8000}
SUPABASE_ANON_KEY: ${ANON_KEY}
SUPABASE_ADMIN_KEY: ${SERVICE_ROLE_KEY}
S3_ENDPOINT: http://${HOST_IP}:9000
API_BASE_URL: ${SITE_URL}
S3_ENDPOINT: http://minio:9000
S3_PUBLIC_ENDPOINT: http://${HOST_IP}:9000
S3_REGION: us-east-1
S3_BUCKET_NAME: ${S3_BUCKET_NAME}
S3_ACCESS_KEY_ID: ${MINIO_ROOT_USER}
S3_SECRET_ACCESS_KEY: ${MINIO_ROOT_PASSWORD}
OBJECT_STORAGE_TYPE: ${OBJECT_STORAGE_TYPE:-s3}
STORAGE_FIXED_QUOTA: ${STORAGE_FIXED_QUOTA:-1073741824}
TRANSLATION_FIXED_QUOTA: ${TRANSLATION_FIXED_QUOTA:-50000}
depends_on:
- kong
- minio
@@ -157,4 +154,4 @@ services:
volumes:
db-config:
db-data:
minio-data:
minio-data: