Replace S3 storage with Docker-mounted filesystem

- Add Docker and Compose deployment with persistent HTML and Postgres volumes
- Remove AWS storage configuration and dependency
- Document local Docker setup and filesystem storage
This commit is contained in:
2026-08-22 00:39:53 -04:00 Verified
parent 75719cefc0
commit 8ac7db77fc
9 changed files with 136 additions and 79 deletions
+11
View File
@@ -0,0 +1,11 @@
.git
.gitignore
node_modules
data
*.md
skills
bin
LICENSE
docker-compose.yml
Dockerfile
.dockerignore
+3
View File
@@ -0,0 +1,3 @@
node_modules
data
.env
+20
View File
@@ -0,0 +1,20 @@
FROM node:20-alpine
WORKDIR /app
COPY package.json ./
RUN npm install --omit=dev && npm cache clean --force
COPY src ./src
ENV NODE_ENV=production
ENV POSTPLAN_HTML_DIR=/data/html
RUN mkdir -p /data/html
EXPOSE 3000
HEALTHCHECK --interval=10s --timeout=5s --start-period=10s --retries=10 \
CMD node -e "fetch('http://127.0.0.1:3000/healthz').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
CMD ["node", "src/server.js"]
+8 -20
View File
@@ -44,14 +44,11 @@ Required service variables:
- `DATABASE_URL`
- `POSTPLAN_BOOTSTRAP_API_KEY`
- `AWS_ENDPOINT_URL`
- `AWS_ACCESS_KEY_ID`
- `AWS_SECRET_ACCESS_KEY`
- `AWS_S3_BUCKET_NAME`
- `AWS_DEFAULT_REGION`
Optional service variables:
- `POSTPLAN_HTML_DIR` - directory for uploaded HTML files (default `./data/html`; Docker Compose mounts this at `/data/html`).
- `POSTPLAN_PUBLIC_BASE_URL` - set to a normal base URL for `/d/<draft-id>` URLs, or a wildcard URL such as `https://*.postplan.dev` for draft subdomains.
- `POSTPLAN_SESSION_SECRET` - together with `POSTPLAN_PUBLIC_BASE_URL`, enables web sign-in (the dashboard and `/cli/auth`). If either is absent, those routes return 503 and uploads/serving are unaffected.
- `SHOO_BASE_URL` - identity broker for web sign-in (default `https://shoo.dev`).
@@ -88,30 +85,21 @@ The upload API returns both `publicUrl` and `rawUrl`, and the CLI prints the raw
- `https://postplan.dev/d/<draft-id>/raw`
- `https://postplan.dev/d/<draft-id>/v/<n>/raw`
## Railway Provisioning
## Docker
After `railway login` succeeds, provision and deploy the first-pass stack:
Uploaded HTML is stored on the filesystem. Compose bind-mounts `./data/html` to `/data/html` in the app container so drafts persist across rebuilds. Override the host path with `POSTPLAN_HTML_VOLUME`.
```sh
chmod +x scripts/provision-railway.sh
scripts/provision-railway.sh
docker compose up --build
```
The script creates a Railway project, app service, Postgres service, object storage bucket, service variables, Railway domain, and first deployment. It stores the generated bootstrap API key under `~/.postplan/deployments/`.
Verify the deployed service:
```sh
POSTPLAN_URL=https://your-railway-domain \
POSTPLAN_API_KEY=your-bootstrap-or-cli-key \
scripts/verify-deployment.sh
```
The API listens on `http://localhost:3000`. Point the CLI at it with `--api-url http://localhost:3000`. The default bootstrap key is `dev-bootstrap-key`; set `POSTPLAN_BOOTSTRAP_API_KEY` to override it.
Create a named key from the bootstrap key:
```sh
curl -X POST "$POSTPLAN_URL/api/api-keys" \
-H "Authorization: Bearer $POSTPLAN_BOOTSTRAP_API_KEY" \
curl -X POST "http://localhost:3000/api/api-keys" \
-H "Authorization: Bearer ${POSTPLAN_BOOTSTRAP_API_KEY:-dev-bootstrap-key}" \
-H "Content-Type: application/json" \
-d '{"name":"local-cli"}'
```
+48
View File
@@ -0,0 +1,48 @@
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: postplan
POSTGRES_PASSWORD: postplan
POSTGRES_DB: postplan
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postplan -d postplan"]
interval: 5s
timeout: 5s
retries: 10
restart: unless-stopped
app:
build: .
ports:
- "${PORT:-3000}:3000"
environment:
PORT: 3000
DATABASE_URL: postgres://postplan:postplan@db:5432/postplan
POSTPLAN_BOOTSTRAP_API_KEY: ${POSTPLAN_BOOTSTRAP_API_KEY:-dev-bootstrap-key}
POSTPLAN_HTML_DIR: /data/html
POSTPLAN_PUBLIC_BASE_URL: ${POSTPLAN_PUBLIC_BASE_URL:-http://localhost:3000}
POSTPLAN_SESSION_SECRET: ${POSTPLAN_SESSION_SECRET:-}
volumes:
- ${POSTPLAN_HTML_VOLUME:-./data/html}:/data/html
depends_on:
db:
condition: service_healthy
healthcheck:
test:
[
"CMD",
"node",
"-e",
"fetch('http://127.0.0.1:3000/healthz').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
]
interval: 10s
timeout: 5s
retries: 10
start_period: 10s
restart: unless-stopped
volumes:
postgres-data:
-1
View File
@@ -22,7 +22,6 @@
"node": ">=20"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.835.0",
"commander": "^14.0.0",
"express": "^5.1.0",
"jose": "^6.2.3",
+4 -9
View File
@@ -4,18 +4,13 @@ export const config = {
bootstrapApiKey: process.env.POSTPLAN_BOOTSTRAP_API_KEY,
publicBaseUrl: process.env.POSTPLAN_PUBLIC_BASE_URL,
maxHtmlBytes: Number(process.env.MAX_HTML_BYTES || 512 * 1024),
// Directory for uploaded HTML. In Docker this is /data/html and should be a
// mounted volume so drafts survive container recreation.
htmlDir: process.env.POSTPLAN_HTML_DIR || "./data/html",
// Web sign-in (dashboard). Absent POSTPLAN_SESSION_SECRET, all web-auth
// routes respond 503 and the API/serving paths are unaffected.
sessionSecret: process.env.POSTPLAN_SESSION_SECRET,
shooBaseUrl: (process.env.SHOO_BASE_URL || "https://shoo.dev").replace(/\/+$/, ""),
s3: {
endpoint: process.env.AWS_ENDPOINT_URL || process.env.S3_ENDPOINT,
accessKeyId: process.env.AWS_ACCESS_KEY_ID || process.env.S3_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || process.env.S3_SECRET_ACCESS_KEY,
bucketName: process.env.AWS_S3_BUCKET_NAME || process.env.S3_BUCKET_NAME,
region: process.env.AWS_DEFAULT_REGION || process.env.AWS_REGION || "auto",
forcePathStyle: (process.env.AWS_S3_FORCE_PATH_STYLE || "true") !== "false"
}
shooBaseUrl: (process.env.SHOO_BASE_URL || "https://shoo.dev").replace(/\/+$/, "")
};
export function requireEnv(name, value) {
+1 -1
View File
@@ -4,7 +4,7 @@ import { ensureBootstrapApiKey, initDb } from "./db.js";
import { assertStorageConfigured } from "./storage.js";
async function main() {
assertStorageConfigured();
await assertStorageConfigured();
await initDb();
await ensureBootstrapApiKey();
+41 -48
View File
@@ -1,59 +1,52 @@
import { GetObjectCommand, PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
import { config, requireEnv } from "./config.js";
import fs from "node:fs/promises";
import path from "node:path";
import { config } from "./config.js";
let client;
function getClient() {
if (client) return client;
client = new S3Client({
endpoint: requireEnv("AWS_ENDPOINT_URL", config.s3.endpoint),
region: requireEnv("AWS_DEFAULT_REGION", config.s3.region),
forcePathStyle: config.s3.forcePathStyle,
credentials: {
accessKeyId: requireEnv("AWS_ACCESS_KEY_ID", config.s3.accessKeyId),
secretAccessKey: requireEnv("AWS_SECRET_ACCESS_KEY", config.s3.secretAccessKey)
}
});
return client;
}
export function assertStorageConfigured() {
requireEnv("AWS_ENDPOINT_URL", config.s3.endpoint);
requireEnv("AWS_ACCESS_KEY_ID", config.s3.accessKeyId);
requireEnv("AWS_SECRET_ACCESS_KEY", config.s3.secretAccessKey);
requireEnv("AWS_S3_BUCKET_NAME", config.s3.bucketName);
export async function assertStorageConfigured() {
const root = htmlRoot();
await fs.mkdir(root, { recursive: true });
await fs.access(root, fs.constants.W_OK);
}
export async function putHtmlObject(key, html) {
assertStorageConfigured();
await getClient().send(
new PutObjectCommand({
Bucket: config.s3.bucketName,
Key: key,
Body: html,
ContentType: "text/html; charset=utf-8",
CacheControl: "no-store"
})
);
const filePath = resolveObjectPath(key);
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, html, "utf8");
}
export async function getHtmlObject(key) {
assertStorageConfigured();
const result = await getClient().send(
new GetObjectCommand({
Bucket: config.s3.bucketName,
Key: key
})
);
return streamToString(result.Body);
const filePath = resolveObjectPath(key);
try {
return await fs.readFile(filePath, "utf8");
} catch (error) {
if (error.code === "ENOENT") {
const missing = new Error("HTML object not found.");
missing.statusCode = 404;
throw missing;
}
throw error;
}
}
async function streamToString(stream) {
const chunks = [];
for await (const chunk of stream) {
chunks.push(Buffer.from(chunk));
function htmlRoot() {
return path.resolve(config.htmlDir);
}
return Buffer.concat(chunks).toString("utf8");
function resolveObjectPath(key) {
const root = htmlRoot();
const normalizedKey = String(key || "").replace(/\\/g, "/");
if (!normalizedKey || path.isAbsolute(normalizedKey) || normalizedKey.includes("\0")) {
throw new Error("Invalid object key.");
}
const parts = normalizedKey.split("/").filter((part) => part && part !== ".");
if (parts.length === 0 || parts.some((part) => part === "..")) {
throw new Error("Invalid object key.");
}
const resolved = path.resolve(root, ...parts);
const relative = path.relative(root, resolved);
if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) {
throw new Error("Invalid object key.");
}
return resolved;
}