Merge pull request 'Replace S3 storage & Railway with Docker' (#1) from t3code/dockerize-server-remove-aws into main
Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
.git
|
||||
.gitignore
|
||||
node_modules
|
||||
data
|
||||
*.md
|
||||
skills
|
||||
bin
|
||||
LICENSE
|
||||
docker-compose.yml
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
@@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
data
|
||||
.env
|
||||
+20
@@ -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"]
|
||||
@@ -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"}'
|
||||
```
|
||||
|
||||
@@ -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:
|
||||
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user