Skip to content

Commit f2c524f

Browse files
m-pavelclaude
andauthored
feat: add base-path support for running behind reverse proxy (#515)
Add --base-path CLI option and BASE_PATH env variable to allow deploying behind a reverse proxy (e.g., nginx). All routes use an Express Router mounted at the base path. All views updated to use basePath for links, assets, fetch URLs, and breadcrumb navigation. Fix mismatched quotes in EJS templates and add missing build step in Dockerfile. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent ff648bc commit f2c524f

16 files changed

Lines changed: 107 additions & 72 deletions

Dockerfile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ COPY rollup.config.ts .
1010
COPY tsconfig.json .
1111

1212
RUN npm ci
13+
RUN npm run build
1314

1415
FROM node:20-alpine
1516
EXPOSE 8001

README.md

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,10 @@ Options:
1818
- `--port PORT` / `-p PORT` - Port to run on (default: 8001)
1919
- `--host HOST` / `-h HOST` - Host to run on (default: localhost)
2020
- `--dynamo-endpoint` - DynamoDB endpoint to connect to (default: http://localhost:8000).
21+
- `--base-path` - Base path for the application (e.g., /dynamo). Useful when running behind a reverse proxy (default: empty).
2122
- `--skip-default-credentials` - Skip setting default credentials and region. By default the accessKeyId/secretAccessKey are set to "key" and "secret" and the region is set to "us-east-1". If you specify this argument then you need to ensure that credentials are provided some other way. See https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/setting-credentials-node.html for more details on how default credentials provider works.
2223

23-
Environment variables `HOST`, `PORT` and `DYNAMO_ENDPOINT` can also be used to set the respective options.
24+
Environment variables `HOST`, `PORT`, `BASE_PATH` and `DYNAMO_ENDPOINT` can also be used to set the respective options.
2425

2526
If you use a local dynamodb that cares about credentials, you can configure them by using the following environment variables `AWS_REGION` `AWS_ACCESS_KEY_ID` `AWS_SECRET_ACCESS_KEY` or specify the `--skip-default-credentials` argument and rely on the default AWS SDK credentials resolving behavior.
2627

@@ -33,6 +34,27 @@ If you are accessing your database from another piece of software, the `AWS_ACCE
3334

3435
By default `dynamodb-admin` sets a default key/secret to values "key" and "secret" and the region to "us-east-1".
3536

37+
### Running behind a reverse proxy
38+
39+
When running behind a reverse proxy like nginx, you can use the `--base-path` option to specify a custom base path:
40+
41+
```bash
42+
dynamodb-admin --base-path=/dynamo
43+
```
44+
45+
Example nginx configuration:
46+
47+
```nginx
48+
location /dynamo/ {
49+
proxy_pass http://localhost:8001/;
50+
proxy_http_version 1.1;
51+
proxy_set_header Upgrade $http_upgrade;
52+
proxy_set_header Connection 'upgrade';
53+
proxy_set_header Host $host;
54+
proxy_cache_bypass $http_upgrade;
55+
}
56+
```
57+
3658
### Use as a library in your project
3759

3860
This requires AWS SDK v3.

bin/dynamodb-admin.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,9 +48,15 @@ parser.add_argument('--skip-default-credentials', {
4848
help: 'Skip setting default credentials and region. By default the accessKeyId/secretAccessKey are set to "key" and "secret" and the region is set to "us-east-1". If you specify this argument then you need to ensure that credentials are provided some other way. See https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/setting-credentials-node.html for more details on how default credentials provider works.',
4949
});
5050

51-
const { host, port, open: openUrl, dynamo_endpoint: dynamoEndpoint, skip_default_credentials: skipDefaultCredentials } = parser.parse_args();
51+
parser.add_argument('--base-path', {
52+
type: 'str',
53+
default: process.env.BASE_PATH || '',
54+
help: 'Base path for the application (e.g., /dynamo). Useful when running behind a reverse proxy.',
55+
});
56+
57+
const { host, port, open: openUrl, dynamo_endpoint: dynamoEndpoint, skip_default_credentials: skipDefaultCredentials, base_path: basePath } = parser.parse_args();
5258

53-
const app = createServer({ dynamoEndpoint, skipDefaultCredentials });
59+
const app = createServer({ dynamoEndpoint, skipDefaultCredentials, basePath });
5460
const server = app.listen(port, host);
5561
server.on('listening', () => {
5662
const address = server.address();

lib/backend.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,11 @@ export type CreateServerOptions = {
1010
expressInstance?: Express;
1111
dynamoEndpoint?: string;
1212
skipDefaultCredentials?: boolean;
13+
basePath?: string;
1314
};
1415

1516
export function createServer(options?: CreateServerOptions): Express {
16-
const { dynamoDbClient, expressInstance, dynamoEndpoint, skipDefaultCredentials } = options || {};
17+
const { dynamoDbClient, expressInstance, dynamoEndpoint, skipDefaultCredentials, basePath = '' } = options || {};
1718
const app = expressInstance || express();
1819
let dynamodb = dynamoDbClient;
1920

@@ -27,7 +28,7 @@ export function createServer(options?: CreateServerOptions): Express {
2728

2829
const ddbApi = new DynamoApiController(dynamodb);
2930

30-
setupRoutes(app, ddbApi);
31+
setupRoutes(app, ddbApi, basePath);
3132

3233
return app;
3334
}

lib/routes.ts

Lines changed: 29 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -14,32 +14,35 @@ import type { DynamoApiController } from './dynamoDbApi';
1414

1515
const DEFAULT_THEME = process.env.DEFAULT_THEME || 'light';
1616

17-
export function setupRoutes(app: Express, ddbApi: DynamoApiController): void {
18-
app.use(errorhandler());
19-
app.use('/assets', express.static(path.join(__dirname, '..', 'public')));
17+
export function setupRoutes(app: Express, ddbApi: DynamoApiController, basePath = ''): void {
18+
const router = express.Router();
2019

21-
app.use(
20+
router.use(errorhandler());
21+
router.use('/assets', express.static(path.join(__dirname, '..', 'public')));
22+
23+
router.use(
2224
cookieParser(),
2325
(req, res, next) => {
2426
const { theme = DEFAULT_THEME } = req.cookies;
2527
res.locals = {
2628
theme,
29+
basePath,
2730
};
2831
next();
2932
},
3033
);
3134

32-
app.get('/', asyncMiddleware(async(_req, res) => {
35+
router.get('/', asyncMiddleware(async(_req, res) => {
3336
const data = await listAllTables(ddbApi);
3437
res.render('tables', { data });
3538
}));
3639

37-
app.get('/api/tables', asyncMiddleware(async(_req, res) => {
40+
router.get('/api/tables', asyncMiddleware(async(_req, res) => {
3841
const data = await listAllTables(ddbApi);
3942
res.send(data);
4043
}));
4144

42-
app.get('/create-table', (_req, res) => {
45+
router.get('/create-table', (_req, res) => {
4346
res.render('create-table', {});
4447
});
4548

@@ -58,7 +61,7 @@ export function setupRoutes(app: Express, ddbApi: DynamoApiController): void {
5861
IndexType: 'global' | 'local';
5962
};
6063

61-
app.post(
64+
router.post(
6265
'/create-table',
6366
bodyParser.json({ limit: '500kb' }),
6467
asyncMiddleware(async(req, res) => {
@@ -170,7 +173,7 @@ export function setupRoutes(app: Express, ddbApi: DynamoApiController): void {
170173
}),
171174
);
172175

173-
app.delete('/tables', asyncMiddleware(async(_req, res) => {
176+
router.delete('/tables', asyncMiddleware(async(_req, res) => {
174177
const tablesList = await listAllTables(ddbApi);
175178
if (tablesList.length === 0) {
176179
res.send('There are no tables to delete');
@@ -180,7 +183,7 @@ export function setupRoutes(app: Express, ddbApi: DynamoApiController): void {
180183
res.send('Tables deleted');
181184
}));
182185

183-
app.delete('/tables-purge', asyncMiddleware(async(req, res) => {
186+
router.delete('/tables-purge', asyncMiddleware(async(req, res) => {
184187
const tablesList = await listAllTables(ddbApi);
185188
if (tablesList.length === 0) {
186189
res.send('There are no tables to purge');
@@ -190,27 +193,27 @@ export function setupRoutes(app: Express, ddbApi: DynamoApiController): void {
190193
res.send('Tables purged');
191194
}));
192195

193-
app.delete('/tables/:TableName', asyncMiddleware(async(req, res) => {
196+
router.delete('/tables/:TableName', asyncMiddleware(async(req, res) => {
194197
const { TableName } = req.params;
195198
await ddbApi.deleteTable({ TableName });
196199
res.status(204).end();
197200
}));
198201

199-
app.delete('/tables/:TableName/all', asyncMiddleware(async(req, res) => {
202+
router.delete('/tables/:TableName/all', asyncMiddleware(async(req, res) => {
200203
const { TableName } = req.params;
201204
await purgeTable(TableName, ddbApi);
202205
res.status(200).end();
203206
}));
204207

205-
app.get('/tables/:TableName/get', asyncMiddleware(async(req, res) => {
208+
router.get('/tables/:TableName/get', asyncMiddleware(async(req, res) => {
206209
const { TableName } = req.params;
207210
const hash = req.query.hash as string;
208211
const range = req.query.range as string;
209212
if (hash) {
210213
if (range) {
211-
res.redirect(`/tables/${encodeURIComponent(TableName)}/items/${encodeURIComponent(hash)},${encodeURIComponent(range)}`);
214+
res.redirect(`${basePath}/tables/${encodeURIComponent(TableName)}/items/${encodeURIComponent(hash)},${encodeURIComponent(range)}`);
212215
} else {
213-
res.redirect(`/tables/${encodeURIComponent(TableName)}/items/${encodeURIComponent(hash)}`);
216+
res.redirect(`${basePath}/tables/${encodeURIComponent(TableName)}/items/${encodeURIComponent(hash)}`);
214217
}
215218
return;
216219
}
@@ -225,7 +228,7 @@ export function setupRoutes(app: Express, ddbApi: DynamoApiController): void {
225228
});
226229
}));
227230

228-
app.get('/tables/:TableName', asyncMiddleware(async(req, res) => {
231+
router.get('/tables/:TableName', asyncMiddleware(async(req, res) => {
229232
const TableName = req.params.TableName;
230233
req.query = pickBy(req.query);
231234
const pageNum = typeof req.query.pageNum === 'string' ? Number.parseInt(req.query.pageNum) : 1;
@@ -252,7 +255,7 @@ export function setupRoutes(app: Express, ddbApi: DynamoApiController): void {
252255
res.render('scan', data);
253256
}));
254257

255-
app.get('/tables/:TableName/items', asyncMiddleware(async(req, res) => {
258+
router.get('/tables/:TableName/items', asyncMiddleware(async(req, res) => {
256259
const { TableName } = req.params;
257260
req.query = pickBy(req.query);
258261
const filters = typeof req.query.filters === 'string' ? JSON.parse(req.query.filters) : {};
@@ -352,7 +355,7 @@ export function setupRoutes(app: Express, ddbApi: DynamoApiController): void {
352355
res.json(data);
353356
}));
354357

355-
app.get('/tables/:TableName/meta', asyncMiddleware(async(req, res) => {
358+
router.get('/tables/:TableName/meta', asyncMiddleware(async(req, res) => {
356359
const { TableName } = req.params;
357360
const [tableDescription, items] = await Promise.all([
358361
ddbApi.describeTable({ TableName }),
@@ -365,7 +368,7 @@ export function setupRoutes(app: Express, ddbApi: DynamoApiController): void {
365368
res.render('meta', data);
366369
}));
367370

368-
app.delete('/tables/:TableName/items/:key', asyncMiddleware(async(req, res) => {
371+
router.delete('/tables/:TableName/items/:key', asyncMiddleware(async(req, res) => {
369372
const { TableName } = req.params;
370373
const tableDescription = await ddbApi.describeTable({ TableName });
371374
await ddbApi.deleteItem({
@@ -375,7 +378,7 @@ export function setupRoutes(app: Express, ddbApi: DynamoApiController): void {
375378
res.status(204).end();
376379
}));
377380

378-
app.get('/tables/:TableName/add-item', asyncMiddleware(async(req, res) => {
381+
router.get('/tables/:TableName/add-item', asyncMiddleware(async(req, res) => {
379382
const { TableName } = req.params;
380383
const tableDescription = await ddbApi.describeTable({ TableName });
381384
const Item: Record<string, string | number> = {};
@@ -397,7 +400,7 @@ export function setupRoutes(app: Express, ddbApi: DynamoApiController): void {
397400
});
398401
}));
399402

400-
app.get('/tables/:TableName/items/:key', asyncMiddleware(async(req, res) => {
403+
router.get('/tables/:TableName/items/:key', asyncMiddleware(async(req, res) => {
401404
const { TableName } = req.params;
402405
const tableDescription = await ddbApi.describeTable({ TableName });
403406
const params = {
@@ -418,7 +421,7 @@ export function setupRoutes(app: Express, ddbApi: DynamoApiController): void {
418421
});
419422
}));
420423

421-
app.put('/tables/:TableName/add-item', bodyParser.json({ limit: '500kb' }), asyncMiddleware(async(req, res) => {
424+
router.put('/tables/:TableName/add-item', bodyParser.json({ limit: '500kb' }), asyncMiddleware(async(req, res) => {
422425
const { TableName } = req.params;
423426
const tableDescription = await ddbApi.describeTable({ TableName });
424427
await ddbApi.putItem({ TableName, Item: req.body });
@@ -432,7 +435,7 @@ export function setupRoutes(app: Express, ddbApi: DynamoApiController): void {
432435
return;
433436
}));
434437

435-
app.put('/tables/:TableName/items/:key', bodyParser.json({ limit: '500kb' }), asyncMiddleware(async(req, res) => {
438+
router.put('/tables/:TableName/items/:key', bodyParser.json({ limit: '500kb' }), asyncMiddleware(async(req, res) => {
436439
const { TableName } = req.params;
437440
const tableDescription = await ddbApi.describeTable({ TableName });
438441
await ddbApi.putItem({ TableName, Item: req.body });
@@ -443,8 +446,10 @@ export function setupRoutes(app: Express, ddbApi: DynamoApiController): void {
443446
res.json(response.Item);
444447
}));
445448

446-
app.use(((error, _req, res, _next) => {
449+
router.use(((error, _req, res, _next) => {
447450
console.info(error.stack);
448451
res.status(500).json({ message: error.message });
449452
}) as ErrorRequestHandler);
453+
454+
app.use(basePath, router);
450455
}

views/create-table.ejs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@
6161
body: JSON.stringify(createTableRequest)
6262
})
6363
if (response.ok) {
64-
window.location = '/'
64+
window.location = '<%= basePath %>/'
6565
} else {
6666
const error = await response.text()
6767
handleError(JSON.parse(error).message)

views/get.ejs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
<%
1111
const breadcrumb = [
1212
{
13-
href: '/',
13+
href: basePath + '/',
1414
text: 'Tables',
1515
},
1616
{
@@ -24,17 +24,17 @@
2424

2525
<ul class='nav nav-tabs'>
2626
<li class='nav-item'>
27-
<a class='nav-link' href='/tables/<%= encodeURIComponent(Table.TableName) %>'>
27+
<a class='nav-link' href='<%= basePath %>/tables/<%= encodeURIComponent(Table.TableName) %>'>
2828
Items
2929
</a>
3030
</li>
3131
<li class="nav-item">
32-
<a class="nav-link active" href="/tables/<%= encodeURIComponent(Table.TableName) %>/get">
32+
<a class="nav-link active" href='<%= basePath %>/tables/<%= encodeURIComponent(Table.TableName) %>/get'>
3333
Get
3434
</a>
3535
</li>
3636
<li class='nav-item'>
37-
<a class='nav-link' href='/tables/<%= encodeURIComponent(Table.TableName) %>/meta'>Meta</a>
37+
<a class='nav-link' href='<%= basePath %>/tables/<%= encodeURIComponent(Table.TableName) %>/meta'>Meta</a>
3838
</li>
3939
</ul>
4040
</header>

views/item.ejs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,11 @@
1111
<%
1212
const breadcrumb = [
1313
{
14-
href: '/',
14+
href: basePath + '/',
1515
text: 'Tables',
1616
},
1717
{
18-
href: `/tables/${encodeURIComponent(TableName)}`,
18+
href: `${basePath}/tables/${encodeURIComponent(TableName)}`,
1919
text: Table.TableName,
2020
badge: Table.ItemCount,
2121
},
@@ -94,7 +94,7 @@
9494
method: 'delete'
9595
}).then(async (response) => {
9696
if (response.ok) {
97-
window.location = `/tables/<%= encodeURIComponent(TableName) %>`
97+
window.location = '<%= basePath %>/tables/<%= encodeURIComponent(TableName) %>'
9898
} else {
9999
const responseText = await response.text()
100100
throw new Error(JSON.parse(responseText).message)

0 commit comments

Comments
 (0)