Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
on:
push:
branches: [main]
branches: [main, staging]
pull_request:
types: [opened, reopened, synchronize]

Expand Down
84 changes: 84 additions & 0 deletions .github/workflows/deploy-staging.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# Kamal deployment workflow for Staging
# Deploys to staging after CI workflow passes on staging branch

name: Deploy Staging

on:
workflow_run:
workflows: [CI]
types: [completed]
branches: [staging]
workflow_dispatch: # Allow manual deployment

concurrency:
group: deploy-staging
cancel-in-progress: true

jobs:
deploy:
name: Deploy to staging
runs-on: ubuntu-latest
environment: Staging
# Only run if CI passed (or manual trigger)
if: >
github.event_name == 'workflow_dispatch' ||
(github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.head_branch == 'staging')

steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.KAMAL_REGISTRY_PASSWORD }}

- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: |
ghcr.io/zainfathoni/kelas.rumahberbagi.com:staging-${{ github.sha }}
ghcr.io/zainfathoni/kelas.rumahberbagi.com:staging-latest
cache-from: type=gha
cache-to: type=gha,mode=max
platforms: linux/amd64

- name: Set up Ruby for Kamal
uses: ruby/setup-ruby@v1
with:
ruby-version: '3.3'

- name: Install Kamal
run: gem install kamal

- name: Set up SSH agent
uses: webfactory/ssh-agent@v0.9.0
with:
ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }}

- name: Add VPS to known hosts
run: ssh-keyscan -t rsa,ecdsa,ed25519 103.235.75.227 >> ~/.ssh/known_hosts

- name: Create Kamal secrets file
run: |
mkdir -p .kamal
cat > .kamal/secrets << EOF
KAMAL_REGISTRY_PASSWORD=${{ secrets.KAMAL_REGISTRY_PASSWORD }}
SESSION_SECRET=${{ secrets.SESSION_SECRET }}
MAGIC_LINK_SECRET=${{ secrets.MAGIC_LINK_SECRET }}
MAILGUN_SENDING_KEY=${{ secrets.MAILGUN_SENDING_KEY }}
MAILGUN_DOMAIN=${{ secrets.MAILGUN_DOMAIN }}
EOF

- name: Deploy with Kamal
run: kamal deploy -c config/deploy.staging.yml --skip-push --version staging-${{ github.sha }}
env:
KAMAL_REGISTRY_PASSWORD: ${{ secrets.KAMAL_REGISTRY_PASSWORD }}
24 changes: 23 additions & 1 deletion app/root.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { LinksFunction } from '@remix-run/node'
import type { LinksFunction, LoaderFunction } from '@remix-run/node'
import { json } from '@remix-run/node'
import {
Form,
isRouteErrorResponse,
Expand All @@ -8,6 +9,7 @@ import {
Outlet,
Scripts,
ScrollRestoration,
useLoaderData,
useMatches,
useRouteError,
} from '@remix-run/react'
Expand All @@ -18,6 +20,24 @@ import fonts from './fonts.css'
import { Footer } from './components/footer'
import { Header } from '~/components/header'

type LoaderData = {
isStaging: boolean
}

export const loader: LoaderFunction = () => {
return json<LoaderData>({
isStaging: process.env.STAGING_ENVIRONMENT === 'true',
})
}

function StagingBanner() {
return (
<div className="bg-yellow-400 text-yellow-900 text-center py-1 px-4 text-sm font-medium">
Staging Environment - Data may be reset at any time
</div>
)
}

// https://remix.run/api/app#links
export const links: LinksFunction = () => {
return [
Expand All @@ -30,8 +50,10 @@ export const links: LinksFunction = () => {
// https://remix.run/api/conventions#default-export
// https://remix.run/api/conventions#route-filenames
export default function App() {
const { isStaging } = useLoaderData<LoaderData>()
return (
<Document>
{isStaging && <StagingBanner />}
<Layout>
<Outlet />
</Layout>
Expand Down
58 changes: 58 additions & 0 deletions config/deploy.staging.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# Kamal 2.0 deployment configuration for Kelas Rumah Berbagi (Staging)
# See: https://kamal-deploy.org/docs/configuration/

service: kelas-staging

# Docker image configuration
image: zainfathoni/kelas.rumahberbagi.com

# Deployment servers
servers:
web:
hosts:
- 103.235.75.227
options:
volume:
- /data/kelas-staging/db:/app/prisma

# Container registry (GitHub Container Registry)
registry:
server: ghcr.io
username: zainfathoni
password:
- KAMAL_REGISTRY_PASSWORD

# Kamal Proxy configuration (replaces Traefik in Kamal 2.0)
proxy:
ssl: true
host: staging.kelas.rumahberbagi.com
app_port: 3000
healthcheck:
path: /
interval: 5
timeout: 5

# Environment variables
env:
# Clear (non-secret) environment variables
clear:
NODE_ENV: production
PORT: "3000"
DATABASE_URL: file:/app/prisma/staging.db
STAGING_ENVIRONMENT: "true"
# Secret environment variables (loaded from .kamal/secrets)
secret:
- SESSION_SECRET
- MAGIC_LINK_SECRET
- MAILGUN_SENDING_KEY
- MAILGUN_DOMAIN

# Build configuration
builder:
arch: amd64
args:
NODE_ENV: production

# Asset bridge for zero-downtime deployments
# Copies public assets from old container to new one during deploy
asset_path: /app/public
56 changes: 55 additions & 1 deletion docs/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@ deployment.

## Overview

- **Production URL**: <https://kelas.rumahberbagi.com>
| Environment | URL | Branch | Config |
| ----------- | ---------------------------------------- | --------- | --------------------------- |
| Production | <https://kelas.rumahberbagi.com> | `main` | `config/deploy.yml` |
| Staging | <https://staging.kelas.rumahberbagi.com> | `staging` | `config/deploy.staging.yml` |

- **VPS**: 103.235.75.227 (Jetorbit)
- **Container Registry**: GitHub Container Registry (ghcr.io)
- **SSL**: Managed by kamal-proxy (Let's Encrypt)
Expand Down Expand Up @@ -101,3 +105,53 @@ kamal app logs
## Database Backups

See [backup-setup.md](backup-setup.md) for database backup configuration.

## Staging Environment

### Staging Deployment

Staging deploys automatically when CI passes on the `staging` branch:

```bash
# Create staging branch from main
git checkout main
git pull
git checkout -b staging
git push -u origin staging
```

### Staging Kamal Commands

```bash
# Deploy to staging
kamal deploy -c config/deploy.staging.yml

# View staging logs
kamal app logs -c config/deploy.staging.yml

# Rollback staging
kamal rollback <version> -c config/deploy.staging.yml

# SSH to staging container
kamal app exec -c config/deploy.staging.yml -i bash
```

### Database Sync (Production → Staging)

To refresh staging with production data:

```bash
ssh root@103.235.75.227 /usr/local/bin/sync-staging-db.sh
```

This script:

1. Stops the staging container
2. Backs up current staging database
3. Copies production database to staging
4. Restarts the staging container

### Staging Backups

Staging database is backed up daily at 4 AM (1 hour after production) with 7-day
retention. Backups are stored in `/var/backups/kelas-staging-db/`.
8 changes: 8 additions & 0 deletions docs/loops/ralph-loop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# Repeated Work

/ralph-loop "Work on ONE of {issue-id} child, pick the most important one. If
one child is already in_progress, resume. After ensuring that particular test
passed, 'land the plane'.

When all children of that issue is closed, output <promise>DONE</promise>"
--max-iterations 10 --completion-promise "DONE"
52 changes: 52 additions & 0 deletions scripts/backup-staging-db.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
#!/bin/bash
# Database backup script for Kelas Rumah Berbagi Staging
# Backs up SQLite database with 7-day retention (shorter than production)
#
# Usage: ./scripts/backup-staging-db.sh
# Setup: Run on VPS via cron for automated daily backups

set -euo pipefail

# Configuration
DB_PATH="/data/kelas-staging/db/staging.db"
BACKUP_DIR="/var/backups/kelas-staging-db"
RETENTION_DAYS=7
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
BACKUP_FILE="${BACKUP_DIR}/staging_${TIMESTAMP}.db"

# Ensure backup directory exists
mkdir -p "${BACKUP_DIR}"

# Check if database exists
if [ ! -f "${DB_PATH}" ]; then
echo "ERROR: Database not found at ${DB_PATH}"
exit 1
fi

# Create backup using SQLite backup command (safe for running database)
if command -v sqlite3 &> /dev/null; then
echo "Creating backup with sqlite3 .backup command..."
sqlite3 "${DB_PATH}" ".backup '${BACKUP_FILE}'"
else
echo "sqlite3 not found, using file copy..."
cp "${DB_PATH}" "${BACKUP_FILE}"
fi

# Verify backup was created
if [ ! -f "${BACKUP_FILE}" ]; then
echo "ERROR: Backup file was not created"
exit 1
fi

BACKUP_SIZE=$(du -h "${BACKUP_FILE}" | cut -f1)
echo "Backup created: ${BACKUP_FILE} (${BACKUP_SIZE})"

# Remove backups older than retention period
echo "Cleaning up backups older than ${RETENTION_DAYS} days..."
find "${BACKUP_DIR}" -name "staging_*.db" -type f -mtime +${RETENTION_DAYS} -delete

# List remaining backups
BACKUP_COUNT=$(find "${BACKUP_DIR}" -name "staging_*.db" -type f | wc -l)
echo "Total backups: ${BACKUP_COUNT}"

echo "Backup completed successfully"
51 changes: 51 additions & 0 deletions scripts/download-staging-backup.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
#!/bin/bash
# Download staging database backups from VPS to local machine
#
# Usage: ./scripts/download-staging-backup.sh [latest|all|<filename>]
# latest - Download most recent backup (default)
# all - Download all backups
# <file> - Download specific backup file

set -euo pipefail

SCRIPT_DIR="$(dirname "$0")"
DEPLOY_CONFIG="${SCRIPT_DIR}/../config/deploy.staging.yml"

# Extract VPS IP from Kamal config
VPS_IP=$(grep -A2 'hosts:' "${DEPLOY_CONFIG}" | grep -oE '[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+')
VPS_HOST="root@${VPS_IP}"
REMOTE_BACKUP_DIR="/var/backups/kelas-staging-db"
LOCAL_BACKUP_DIR="$(dirname "$0")/../prisma/backups/staging"

# Ensure local backup directory exists
mkdir -p "${LOCAL_BACKUP_DIR}"

MODE="${1:-latest}"

case "${MODE}" in
latest)
echo "Fetching latest staging backup..."
LATEST=$(ssh "${VPS_HOST}" "ls -t ${REMOTE_BACKUP_DIR}/staging_*.db 2>/dev/null | head -1")
if [ -z "${LATEST}" ]; then
echo "ERROR: No backups found on server"
exit 1
fi
FILENAME=$(basename "${LATEST}")
echo "Downloading ${FILENAME}..."
scp "${VPS_HOST}:${LATEST}" "${LOCAL_BACKUP_DIR}/"
echo "Downloaded to ${LOCAL_BACKUP_DIR}/${FILENAME}"
;;
all)
echo "Downloading all staging backups..."
scp "${VPS_HOST}:${REMOTE_BACKUP_DIR}/staging_*.db" "${LOCAL_BACKUP_DIR}/"
echo "Downloaded to ${LOCAL_BACKUP_DIR}/"
ls -lh "${LOCAL_BACKUP_DIR}"
;;
*)
echo "Downloading ${MODE}..."
scp "${VPS_HOST}:${REMOTE_BACKUP_DIR}/${MODE}" "${LOCAL_BACKUP_DIR}/"
echo "Downloaded to ${LOCAL_BACKUP_DIR}/${MODE}"
;;
esac

echo "Done"
Loading
Loading