chore: update Dockerfile and docker-compose for improved deployment process; add deploy script

This commit is contained in:
asepharyana
2026-07-05 22:06:52 +07:00
parent 20b622c4c2
commit bbf420759b
10 changed files with 236 additions and 23 deletions
+4 -6
View File
@@ -3,16 +3,14 @@ FROM oven/bun:alpine AS builder
WORKDIR /usr/src/app
# Install dependencies (including devDependencies for build and lint)
COPY package.json tsconfig.json biome.json ./
# Install dependencies (devDependencies included for build)
COPY package.json tsconfig.json ./
RUN bun install
# Copy src and test directories
# Copy source
COPY src ./src
COPY test ./test
# Run lint and build
RUN bun run lint
# Build (lint is run locally before deploy)
RUN bun run build
# Stage 2: Runner
Executable
+213
View File
@@ -0,0 +1,213 @@
#!/bin/bash
# ─── TeleUploader Deploy Script ──────────────────────────────────────────────
# Builds the Bun app locally and deploys to the VPS via Docker.
#
# Strategy: build dist locally, ship dist + Docker context to VPS via tar pipe,
# then rebuild the Docker image and restart the container on the VPS.
#
# Prerequisites:
# - GitLab CLI (glab) with active session, OR the env vars below
# - SSH access to the VPS
# - Docker + docker compose on the VPS
#
# Usage:
# ./deploy.sh # build + deploy
# ./deploy.sh --no-build # skip build, just deploy dist
# ./deploy.sh --help # show this message
# ./deploy.sh --check # dry-run: show vars and exit
#
# Required env (or auto-fetched from GitLab CI vars via glab):
# VPS_HOST — VPS IP/hostname
# VPS_USER — SSH user (default: root)
# VPS_SSH_KEY — path/contents of SSH private key
#
# Optional:
# DEPLOY_DIR — deploy dir on VPS (default: /opt/teleuploader)
# ADMIN_PASSWORD — verify health after deploy (optional)
# ──────────────────────────────────────────────────────────────────────────────
set -eu
# ── Config ────────────────────────────────────────────────────────────────────
APP_NAME="teleuploader"
GITLAB_PROJECT="superaseph%2FTeleUploader"
DEPLOY_DIR="${DEPLOY_DIR:-/opt/${APP_NAME}}"
COMPOSE_FILE="docker-compose.yml"
DOCKER_IMAGE="ghcr.io/mytheclipse/${APP_NAME}"
# ── Parse args ────────────────────────────────────────────────────────────────
DO_BUILD=true
DO_CHECK=false
for arg in "$@"; do
case "$arg" in
--help|-h)
sed -n '2,/^$/ s/^# //p' "$0"
exit 0
;;
--no-build) DO_BUILD=false ;;
--check) DO_CHECK=true ;;
esac
done
# ── Auto-fetch credentials from GitLab CI vars ─────────────────────────────
fetch_ci_var() {
glab api "projects/${GITLAB_PROJECT}/variables/$1" 2>/dev/null \
| python3 -c "import json,sys; print(json.load(sys.stdin).get('value',''))" 2>/dev/null || true
}
# ── Default credentials ─────────────────────────────────────────────────────
# Hardcoded defaults for this project. Env vars take precedence.
: "${VPS_HOST:=45.127.35.244}"
: "${VPS_USER:=root}"
: "${VPS_SSH_KEY:=${HOME}/.ssh/id_ed25519}"
# Fallback: fetch from GitLab CI vars if defaults are empty (for CI runs)
if [ -z "${VPS_HOST:-}" ]; then VPS_HOST=$(fetch_ci_var VPS_HOST); fi
if [ -z "${VPS_USER:-}" ]; then VPS_USER=$(fetch_ci_var VPS_USERNAME); fi
if [ -z "${VPS_SSH_KEY:-}" ]; then
KEY=$(fetch_ci_var VPS_SSH_KEY)
if [ -n "$KEY" ]; then
VPS_SSH_KEY=$(mktemp)
echo "$KEY" > "$VPS_SSH_KEY"
chmod 600 "$VPS_SSH_KEY"
fi
fi
# ── Check mode ────────────────────────────────────────────────────────────────
if $DO_CHECK; then
echo "=== Config ==="
echo "App name: $APP_NAME"
echo "Deploy dir: $DEPLOY_DIR"
echo "Image: $DOCKER_IMAGE"
echo ""
echo "=== Credentials ==="
echo "VPS_HOST: ${VPS_HOST:-<not set>}"
echo "VPS_USER: ${VPS_USER:-<not set>}"
echo "VPS_SSH_KEY: ${VPS_SSH_KEY:+<set (${#VPS_SSH_KEY} chars)>}"
echo ""
echo "=== Files to deploy ==="
for f in .env package.json bun.lock schema.sql Dockerfile docker-compose.yml dist/index.js dist/migrate.js; do
[ -e "$f" ] && echo "$f" || echo "$f (missing)"
done
exit 0
fi
# ── Validate ──────────────────────────────────────────────────────────────────
# (Defaults are set above — this fails only if something went wrong)
: "${VPS_HOST:?VPS_HOST resolved to empty}"
: "${VPS_USER:?VPS_USER resolved to empty}"
: "${VPS_SSH_KEY:?VPS_SSH_KEY resolved to empty}"
[ -f "$VPS_SSH_KEY" ] || die "SSH key not found at $VPS_SSH_KEY"
SSH_DEST="${VPS_USER}@${VPS_HOST}"
SSH_OPTS="-i $VPS_SSH_KEY -o StrictHostKeyChecking=accept-new"
# ── Helpers ───────────────────────────────────────────────────────────────────
vps() { ssh $SSH_OPTS "$SSH_DEST" "$@"; }
log() { echo "$*"; }
ok() { echo "$*"; }
die() { echo "$*"; exit 1; }
# ── 1. Test SSH connection ────────────────────────────────────────────────────
log "Testing SSH connection to ${VPS_USER}@${VPS_HOST}..."
vps "echo connected" > /dev/null 2>&1 || die "SSH connection failed"
ok "SSH connection established"
# ── 2. Build ──────────────────────────────────────────────────────────────────
REPO_ROOT=$(cd "$(dirname "$0")" && pwd)
cd "$REPO_ROOT"
if $DO_BUILD; then
log "Installing dependencies..."
bun install 2>&1 | tail -1 || die "bun install failed"
log "Formatting code..."
bun run format 2>&1 | tail -3 || log "Format skipped (may be clean)"
log "Linting..."
bun run lint 2>&1 | tail -5 || die "Lint failed"
log "Building dist..."
bun run build 2>&1 || die "Build failed"
# Verify dist output exists
[ -f dist/index.js ] || die "dist/index.js not found after build"
[ -f dist/migrate.js ] || die "dist/migrate.js not found after build"
ok "Build complete (dist/index.js: $(wc -c < dist/index.js | numfmt --to=iec) — dist/migrate.js: $(wc -c < dist/migrate.js | numfmt --to=iec))"
else
log "Skipping build (--no-build)"
fi
# ── 3. Ensure remote deploy directory exists ─────────────────────────────────
log "Ensuring remote directory ${DEPLOY_DIR} exists..."
vps "mkdir -p '${DEPLOY_DIR}'"
ok "Remote directory ready"
# ── 4. Deploy to VPS ─────────────────────────────────────────────────────────
log "Creating deploy archive..."
# Build context: everything needed for `docker compose build` on the VPS
DEPLOY_FILES=(
.env
package.json
bun.lock
schema.sql
Dockerfile
docker-compose.yml
biome.json
tsconfig.json
src
dist
)
log "Shipping files to VPS..."
# Atomic deploy: extract into temp dir, then rename — avoids partial state
vps "rm -rf '${DEPLOY_DIR}.new' && mkdir -p '${DEPLOY_DIR}.new'"
tar czf - "${DEPLOY_FILES[@]}" | vps "tar xzf - -C '${DEPLOY_DIR}.new'"
vps "rm -rf '${DEPLOY_DIR}.old' && mv '${DEPLOY_DIR}' '${DEPLOY_DIR}.old' 2>/dev/null; mv '${DEPLOY_DIR}.new' '${DEPLOY_DIR}' && rm -rf '${DEPLOY_DIR}.old'"
ok "Files shipped to ${DEPLOY_DIR}"
# ── 5. Build Docker image & restart on VPS ───────────────────────────────────
log "Building Docker image on VPS..."
vps "cd '${DEPLOY_DIR}' && docker compose build --pull 2>&1" | tail -5 || die "Docker build failed on VPS"
log "Restarting container..."
vps "cd '${DEPLOY_DIR}' && docker compose up -d --force-recreate 2>&1" || die "Container restart failed"
# ── 6. Verify container is running ────────────────────────────────────────────
log "Waiting for container to be healthy..."
sleep 5
CONTAINER_ID=$(vps "docker ps --filter 'name=${APP_NAME}' --format '{{.ID}}' 2>/dev/null" || true)
if [ -n "$CONTAINER_ID" ]; then
HEALTH=$(vps "docker inspect --format='{{.State.Health.Status}}' '${CONTAINER_ID}'" 2>/dev/null || echo "no-healthcheck")
STATUS=$(vps "docker inspect --format='{{.State.Status}}' '${CONTAINER_ID}'" 2>/dev/null || echo "unknown")
log "Container status: ${STATUS} | health: ${HEALTH}"
# Tail recent logs
vps "docker logs --tail 10 '${CONTAINER_ID}' 2>&1" || true
else
log "No container found with name '${APP_NAME}' — checking all recent..."
vps "docker ps -a --filter 'name=${APP_NAME}' 2>/dev/null" || true
fi
# ── 7. Health check ───────────────────────────────────────────────────────────
if [ -n "${ADMIN_PASSWORD:-}" ]; then
log "Running health check via HTTP..."
sleep 3
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" "https://${VPS_HOST}/health" 2>/dev/null || echo "000")
if [ "$HTTP_CODE" = "200" ]; then
ok "Health check passed (HTTP ${HTTP_CODE})"
else
log "Health check returned HTTP ${HTTP_CODE} (may need a moment or TLS not set up)"
fi
fi
# ── Cleanup temp SSH key ─────────────────────────────────────────────────────
if [[ "${VPS_SSH_KEY:-}" == /tmp/* ]]; then
rm -f "$VPS_SSH_KEY"
fi
echo ""
echo "✓ Deploy complete — ${APP_NAME} is running on ${VPS_HOST}"
+3 -4
View File
@@ -1,8 +1,6 @@
version: '3.8'
services:
app:
image: ghcr.io/mytheclipse/teleuploader:latest
build: .
container_name: teleuploader-app
restart: always
environment:
@@ -26,6 +24,7 @@ services:
read_only: true
tmpfs:
- /tmp:size=4g,mode=1777
- /usr/src/app/logs:size=64m,mode=1777
deploy:
resources:
limits:
@@ -37,7 +36,7 @@ services:
healthcheck:
test:
- CMD-SHELL
- curl -sf http://localhost:3000/health || exit 1
- "bun -e \"fetch('http://localhost:3000/health').then(r => r.status === 200 ? process.exit(0) : process.exit(1))\""
interval: 30s
timeout: 10s
retries: 3
+4 -2
View File
@@ -52,11 +52,13 @@ const maskSecret = (value: string): string => {
return `${value.slice(0, 6)}...${value.slice(-4)}`;
};
const maskDatabaseUrl = (value: string): string => value.replace(/:\/\/([^:]+):([^@]+)@/, '://$1:***@');
const maskDatabaseUrl = (value: string): string =>
value.replace(/:\/\/([^:]+):([^@]+)@/, '://$1:***@');
export const config: AppConfig = {
botToken: process.env.BOT_TOKEN!,
additionalBotTokens: process.env.NODE_ENV === 'test' ? [] : parseTokens(process.env.ADDITIONAL_BOT_TOKENS),
additionalBotTokens:
process.env.NODE_ENV === 'test' ? [] : parseTokens(process.env.ADDITIONAL_BOT_TOKENS),
storageChatId: parseInt(process.env.STORAGE_CHANNEL_ID!, 10),
baseUrl: process.env.BASE_URL!,
databaseUrl: process.env.DATABASE_URL!,
+1 -2
View File
@@ -43,8 +43,7 @@ const cleanupTempFile = async (tempPath: string): Promise<void> => {
const sanitizeFilenameHeader = (fileName: string): string =>
fileName.replace(/[\\"]/g, '').replace(/[\n\r]/g, '');
const fail = (status: number, error: string): Response =>
Response.json({ error }, { status });
const fail = (status: number, error: string): Response => Response.json({ error }, { status });
export const handleFileRedirect = async (req: RequestWithParams): Promise<Response> => {
const public_id = req.params?.public_id;
+2 -1
View File
@@ -90,7 +90,8 @@ export const handleSwaggerJson = async (): Promise<Response> => {
'/api/upload': {
post: {
summary: 'Upload File',
description: 'Uploads a file to Telegram storage via multipart/form-data or JSON base64. Rate-limited by IP.',
description:
'Uploads a file to Telegram storage via multipart/form-data or JSON base64. Rate-limited by IP.',
requestBody: {
required: true,
content: {
+4 -1
View File
@@ -231,7 +231,10 @@ const handleJSONUpload = async (req: Request): Promise<Response> => {
const { base64Data, mimeType: rawMimeType } = parseBase64File(file);
const estimatedSizeBytes = Math.floor((base64Data.length * 3) / 4);
if (estimatedSizeBytes > JSON_UPLOAD_LIMIT_BYTES || estimatedSizeBytes > config.maxRequestBodyBytes) {
if (
estimatedSizeBytes > JSON_UPLOAD_LIMIT_BYTES ||
estimatedSizeBytes > config.maxRequestBodyBytes
) {
return Response.json(
{
error:
-5
View File
@@ -5,11 +5,6 @@ import { enqueueUpload } from './telegramQueue';
const botTokens = Array.from(new Set([config.botToken, ...config.additionalBotTokens]));
type FileInfoResult = {
result: unknown;
botToken: string;
};
const bots = botTokens.map((token) => new Telegraf(token));
let nextBotIndex = 0;
+4 -1
View File
@@ -140,7 +140,10 @@ export const enqueuePreparedUpload = (item: BatchUploadItem): Promise<UploadedFi
}, BATCH_WINDOW_MS);
}
if (pendingUploads.length >= config.batchMaxItems || getPendingSize() >= config.batchMaxSizeBytes) {
if (
pendingUploads.length >= config.batchMaxItems ||
getPendingSize() >= config.batchMaxSizeBytes
) {
void flushUploads();
}
});
+1 -1
View File
@@ -1,8 +1,8 @@
import { once } from 'node:events';
import { createReadStream, createWriteStream } from 'node:fs';
import { finished } from 'node:stream/promises';
import { open, stat } from 'node:fs/promises';
import { basename } from 'node:path';
import { finished } from 'node:stream/promises';
import { nanoid } from 'nanoid';
export type ZipInputFile = {